@miragon/pulse-analytics-connector 0.0.0-bootstrap.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (357) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -3
  3. package/dist/bpmn-name-lookup.d.ts +72 -0
  4. package/dist/bpmn-name-lookup.d.ts.map +1 -0
  5. package/dist/bpmn-name-lookup.js +123 -0
  6. package/dist/bpmn-name-lookup.js.map +1 -0
  7. package/dist/bpmn-name-lookup.test.d.ts +2 -0
  8. package/dist/bpmn-name-lookup.test.d.ts.map +1 -0
  9. package/dist/bpmn-name-lookup.test.js +287 -0
  10. package/dist/bpmn-name-lookup.test.js.map +1 -0
  11. package/dist/bpmn-names.d.ts +38 -0
  12. package/dist/bpmn-names.d.ts.map +1 -0
  13. package/dist/bpmn-names.js +22 -0
  14. package/dist/bpmn-names.js.map +1 -0
  15. package/dist/brand-gates.test.d.ts +2 -0
  16. package/dist/brand-gates.test.d.ts.map +1 -0
  17. package/dist/brand-gates.test.js +58 -0
  18. package/dist/brand-gates.test.js.map +1 -0
  19. package/dist/builder-texts.d.ts +4 -0
  20. package/dist/builder-texts.d.ts.map +1 -0
  21. package/dist/builder-texts.js +14 -0
  22. package/dist/builder-texts.js.map +1 -0
  23. package/dist/builder-texts.test.d.ts +2 -0
  24. package/dist/builder-texts.test.d.ts.map +1 -0
  25. package/dist/builder-texts.test.js +32 -0
  26. package/dist/builder-texts.test.js.map +1 -0
  27. package/dist/definition.d.ts +3 -0
  28. package/dist/definition.d.ts.map +1 -0
  29. package/dist/definition.js +151 -0
  30. package/dist/definition.js.map +1 -0
  31. package/dist/engine-ids.d.ts +42 -0
  32. package/dist/engine-ids.d.ts.map +1 -0
  33. package/dist/engine-ids.js +50 -0
  34. package/dist/engine-ids.js.map +1 -0
  35. package/dist/engine-scope.test.d.ts +2 -0
  36. package/dist/engine-scope.test.d.ts.map +1 -0
  37. package/dist/engine-scope.test.js +260 -0
  38. package/dist/engine-scope.test.js.map +1 -0
  39. package/dist/heatmap-data.d.ts +15 -0
  40. package/dist/heatmap-data.d.ts.map +1 -0
  41. package/dist/heatmap-data.js +2 -0
  42. package/dist/heatmap-data.js.map +1 -0
  43. package/dist/index.d.ts +5 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/instructions.d.ts +8 -0
  48. package/dist/instructions.d.ts.map +1 -0
  49. package/dist/instructions.js +33 -0
  50. package/dist/instructions.js.map +1 -0
  51. package/dist/messages/de.ask-ai.d.ts +9 -0
  52. package/dist/messages/de.ask-ai.d.ts.map +1 -0
  53. package/dist/messages/de.ask-ai.js +18 -0
  54. package/dist/messages/de.ask-ai.js.map +1 -0
  55. package/dist/messages/de.builder.d.ts +12 -0
  56. package/dist/messages/de.builder.d.ts.map +1 -0
  57. package/dist/messages/de.builder.js +53 -0
  58. package/dist/messages/de.builder.js.map +1 -0
  59. package/dist/messages/de.server.d.ts +8 -0
  60. package/dist/messages/de.server.d.ts.map +1 -0
  61. package/dist/messages/de.server.js +26 -0
  62. package/dist/messages/de.server.js.map +1 -0
  63. package/dist/messages/de.sweep.d.ts +10 -0
  64. package/dist/messages/de.sweep.d.ts.map +1 -0
  65. package/dist/messages/de.sweep.js +203 -0
  66. package/dist/messages/de.sweep.js.map +1 -0
  67. package/dist/messages/en.ask-ai.d.ts +25 -0
  68. package/dist/messages/en.ask-ai.d.ts.map +1 -0
  69. package/dist/messages/en.ask-ai.js +24 -0
  70. package/dist/messages/en.ask-ai.js.map +1 -0
  71. package/dist/messages/en.builder.d.ts +12 -0
  72. package/dist/messages/en.builder.d.ts.map +1 -0
  73. package/dist/messages/en.builder.js +53 -0
  74. package/dist/messages/en.builder.js.map +1 -0
  75. package/dist/messages/en.server.d.ts +8 -0
  76. package/dist/messages/en.server.d.ts.map +1 -0
  77. package/dist/messages/en.server.js +26 -0
  78. package/dist/messages/en.server.js.map +1 -0
  79. package/dist/messages/en.sweep.d.ts +11 -0
  80. package/dist/messages/en.sweep.d.ts.map +1 -0
  81. package/dist/messages/en.sweep.js +204 -0
  82. package/dist/messages/en.sweep.js.map +1 -0
  83. package/dist/messages/glossary.d.ts +26 -0
  84. package/dist/messages/glossary.d.ts.map +1 -0
  85. package/dist/messages/glossary.js +37 -0
  86. package/dist/messages/glossary.js.map +1 -0
  87. package/dist/messages/glossary.test.d.ts +2 -0
  88. package/dist/messages/glossary.test.d.ts.map +1 -0
  89. package/dist/messages/glossary.test.js +109 -0
  90. package/dist/messages/glossary.test.js.map +1 -0
  91. package/dist/messages/index.d.ts +12 -0
  92. package/dist/messages/index.d.ts.map +1 -0
  93. package/dist/messages/index.js +23 -0
  94. package/dist/messages/index.js.map +1 -0
  95. package/dist/messages/plural.d.ts +10 -0
  96. package/dist/messages/plural.d.ts.map +1 -0
  97. package/dist/messages/plural.js +17 -0
  98. package/dist/messages/plural.js.map +1 -0
  99. package/dist/messages/use-t.d.ts +10 -0
  100. package/dist/messages/use-t.d.ts.map +1 -0
  101. package/dist/messages/use-t.js +10 -0
  102. package/dist/messages/use-t.js.map +1 -0
  103. package/dist/module.d.ts +48 -0
  104. package/dist/module.d.ts.map +1 -0
  105. package/dist/module.js +174 -0
  106. package/dist/module.js.map +1 -0
  107. package/dist/module.test.d.ts +2 -0
  108. package/dist/module.test.d.ts.map +1 -0
  109. package/dist/module.test.js +230 -0
  110. package/dist/module.test.js.map +1 -0
  111. package/dist/named-views.d.ts +27 -0
  112. package/dist/named-views.d.ts.map +1 -0
  113. package/dist/named-views.js +73 -0
  114. package/dist/named-views.js.map +1 -0
  115. package/dist/named-views.test.d.ts +2 -0
  116. package/dist/named-views.test.d.ts.map +1 -0
  117. package/dist/named-views.test.js +110 -0
  118. package/dist/named-views.test.js.map +1 -0
  119. package/dist/plugin.d.ts +44 -0
  120. package/dist/plugin.d.ts.map +1 -0
  121. package/dist/plugin.js +52 -0
  122. package/dist/plugin.js.map +1 -0
  123. package/dist/prometheus-wiring.test.d.ts +2 -0
  124. package/dist/prometheus-wiring.test.d.ts.map +1 -0
  125. package/dist/prometheus-wiring.test.js +142 -0
  126. package/dist/prometheus-wiring.test.js.map +1 -0
  127. package/dist/server-locale.d.ts +48 -0
  128. package/dist/server-locale.d.ts.map +1 -0
  129. package/dist/server-locale.js +33 -0
  130. package/dist/server-locale.js.map +1 -0
  131. package/dist/settings-tools.d.ts +23 -0
  132. package/dist/settings-tools.d.ts.map +1 -0
  133. package/dist/settings-tools.js +100 -0
  134. package/dist/settings-tools.js.map +1 -0
  135. package/dist/settings.d.ts +77 -0
  136. package/dist/settings.d.ts.map +1 -0
  137. package/dist/settings.js +99 -0
  138. package/dist/settings.js.map +1 -0
  139. package/dist/settings.test.d.ts +2 -0
  140. package/dist/settings.test.d.ts.map +1 -0
  141. package/dist/settings.test.js +299 -0
  142. package/dist/settings.test.js.map +1 -0
  143. package/dist/steps/app-config.d.ts +30 -0
  144. package/dist/steps/app-config.d.ts.map +1 -0
  145. package/dist/steps/app-config.js +46 -0
  146. package/dist/steps/app-config.js.map +1 -0
  147. package/dist/steps/dashboard.d.ts +12 -0
  148. package/dist/steps/dashboard.d.ts.map +1 -0
  149. package/dist/steps/dashboard.js +58 -0
  150. package/dist/steps/dashboard.js.map +1 -0
  151. package/dist/steps/failure-dashboard.d.ts +10 -0
  152. package/dist/steps/failure-dashboard.d.ts.map +1 -0
  153. package/dist/steps/failure-dashboard.js +32 -0
  154. package/dist/steps/failure-dashboard.js.map +1 -0
  155. package/dist/steps/index.d.ts +3 -0
  156. package/dist/steps/index.d.ts.map +1 -0
  157. package/dist/steps/index.js +3 -0
  158. package/dist/steps/index.js.map +1 -0
  159. package/dist/steps/steps.test.d.ts +2 -0
  160. package/dist/steps/steps.test.d.ts.map +1 -0
  161. package/dist/steps/steps.test.js +153 -0
  162. package/dist/steps/steps.test.js.map +1 -0
  163. package/dist/tool-names.d.ts +13 -0
  164. package/dist/tool-names.d.ts.map +1 -0
  165. package/dist/tool-names.js +27 -0
  166. package/dist/tool-names.js.map +1 -0
  167. package/dist/tools/cluster-compare.d.ts +8 -0
  168. package/dist/tools/cluster-compare.d.ts.map +1 -0
  169. package/dist/tools/cluster-compare.js +17 -0
  170. package/dist/tools/cluster-compare.js.map +1 -0
  171. package/dist/tools/element.d.ts +8 -0
  172. package/dist/tools/element.d.ts.map +1 -0
  173. package/dist/tools/element.js +25 -0
  174. package/dist/tools/element.js.map +1 -0
  175. package/dist/tools/engine-compare.d.ts +8 -0
  176. package/dist/tools/engine-compare.d.ts.map +1 -0
  177. package/dist/tools/engine-compare.js +18 -0
  178. package/dist/tools/engine-compare.js.map +1 -0
  179. package/dist/tools/engine-compare.test.d.ts +2 -0
  180. package/dist/tools/engine-compare.test.d.ts.map +1 -0
  181. package/dist/tools/engine-compare.test.js +166 -0
  182. package/dist/tools/engine-compare.test.js.map +1 -0
  183. package/dist/tools/engine-landscape.d.ts +7 -0
  184. package/dist/tools/engine-landscape.d.ts.map +1 -0
  185. package/dist/tools/engine-landscape.js +13 -0
  186. package/dist/tools/engine-landscape.js.map +1 -0
  187. package/dist/tools/engine-landscape.test.d.ts +2 -0
  188. package/dist/tools/engine-landscape.test.d.ts.map +1 -0
  189. package/dist/tools/engine-landscape.test.js +110 -0
  190. package/dist/tools/engine-landscape.test.js.map +1 -0
  191. package/dist/tools/failures.d.ts +7 -0
  192. package/dist/tools/failures.d.ts.map +1 -0
  193. package/dist/tools/failures.js +13 -0
  194. package/dist/tools/failures.js.map +1 -0
  195. package/dist/tools/health.d.ts +7 -0
  196. package/dist/tools/health.d.ts.map +1 -0
  197. package/dist/tools/health.js +23 -0
  198. package/dist/tools/health.js.map +1 -0
  199. package/dist/tools/health.test.d.ts +2 -0
  200. package/dist/tools/health.test.d.ts.map +1 -0
  201. package/dist/tools/health.test.js +119 -0
  202. package/dist/tools/health.test.js.map +1 -0
  203. package/dist/tools/index.d.ts +18 -0
  204. package/dist/tools/index.d.ts.map +1 -0
  205. package/dist/tools/index.js +35 -0
  206. package/dist/tools/index.js.map +1 -0
  207. package/dist/tools/performance.d.ts +8 -0
  208. package/dist/tools/performance.d.ts.map +1 -0
  209. package/dist/tools/performance.js +25 -0
  210. package/dist/tools/performance.js.map +1 -0
  211. package/dist/tools/performance.test.d.ts +2 -0
  212. package/dist/tools/performance.test.d.ts.map +1 -0
  213. package/dist/tools/performance.test.js +176 -0
  214. package/dist/tools/performance.test.js.map +1 -0
  215. package/dist/tools/version-compare.d.ts +8 -0
  216. package/dist/tools/version-compare.d.ts.map +1 -0
  217. package/dist/tools/version-compare.js +17 -0
  218. package/dist/tools/version-compare.js.map +1 -0
  219. package/dist/tools/version-compare.test.d.ts +2 -0
  220. package/dist/tools/version-compare.test.d.ts.map +1 -0
  221. package/dist/tools/version-compare.test.js +82 -0
  222. package/dist/tools/version-compare.test.js.map +1 -0
  223. package/dist/toolsets.d.ts +17 -0
  224. package/dist/toolsets.d.ts.map +1 -0
  225. package/dist/toolsets.js +47 -0
  226. package/dist/toolsets.js.map +1 -0
  227. package/dist/toolsets.test.d.ts +2 -0
  228. package/dist/toolsets.test.d.ts.map +1 -0
  229. package/dist/toolsets.test.js +91 -0
  230. package/dist/toolsets.test.js.map +1 -0
  231. package/dist/view-title.test.d.ts +2 -0
  232. package/dist/view-title.test.d.ts.map +1 -0
  233. package/dist/view-title.test.js +91 -0
  234. package/dist/view-title.test.js.map +1 -0
  235. package/dist/widget-tools/comparisons.d.ts +13 -0
  236. package/dist/widget-tools/comparisons.d.ts.map +1 -0
  237. package/dist/widget-tools/comparisons.js +177 -0
  238. package/dist/widget-tools/comparisons.js.map +1 -0
  239. package/dist/widget-tools/comparisons.test.d.ts +2 -0
  240. package/dist/widget-tools/comparisons.test.d.ts.map +1 -0
  241. package/dist/widget-tools/comparisons.test.js +123 -0
  242. package/dist/widget-tools/comparisons.test.js.map +1 -0
  243. package/dist/widget-tools/shared.d.ts +36 -0
  244. package/dist/widget-tools/shared.d.ts.map +1 -0
  245. package/dist/widget-tools/shared.js +39 -0
  246. package/dist/widget-tools/shared.js.map +1 -0
  247. package/dist/widget-tools.d.ts +25 -0
  248. package/dist/widget-tools.d.ts.map +1 -0
  249. package/dist/widget-tools.js +269 -0
  250. package/dist/widget-tools.js.map +1 -0
  251. package/dist/widget-tools.test.d.ts +2 -0
  252. package/dist/widget-tools.test.d.ts.map +1 -0
  253. package/dist/widget-tools.test.js +272 -0
  254. package/dist/widget-tools.test.js.map +1 -0
  255. package/package.json +91 -5
  256. package/src/bpmn-name-lookup.test.ts +333 -0
  257. package/src/bpmn-name-lookup.ts +197 -0
  258. package/src/bpmn-names.ts +51 -0
  259. package/src/brand-gates.test.ts +69 -0
  260. package/src/builder-texts.test.ts +40 -0
  261. package/src/builder-texts.ts +15 -0
  262. package/src/definition.ts +181 -0
  263. package/src/engine-ids.ts +85 -0
  264. package/src/engine-scope.test.ts +324 -0
  265. package/src/heatmap-data.ts +14 -0
  266. package/src/index.ts +4 -0
  267. package/src/instructions.ts +33 -0
  268. package/src/messages/GLOSSARY.md +88 -0
  269. package/src/messages/de.ask-ai.ts +28 -0
  270. package/src/messages/de.builder.ts +77 -0
  271. package/src/messages/de.server.ts +39 -0
  272. package/src/messages/de.sweep.ts +244 -0
  273. package/src/messages/en.ask-ai.ts +34 -0
  274. package/src/messages/en.builder.ts +76 -0
  275. package/src/messages/en.server.ts +39 -0
  276. package/src/messages/en.sweep.ts +240 -0
  277. package/src/messages/glossary.test.ts +122 -0
  278. package/src/messages/glossary.ts +62 -0
  279. package/src/messages/index.ts +24 -0
  280. package/src/messages/plural.ts +18 -0
  281. package/src/messages/use-t.ts +12 -0
  282. package/src/module.test.ts +319 -0
  283. package/src/module.ts +211 -0
  284. package/src/named-views.test.ts +141 -0
  285. package/src/named-views.ts +97 -0
  286. package/src/plugin.ts +94 -0
  287. package/src/prometheus-wiring.test.ts +174 -0
  288. package/src/server-locale.ts +59 -0
  289. package/src/settings-tools.ts +170 -0
  290. package/src/settings.test.ts +402 -0
  291. package/src/settings.ts +114 -0
  292. package/src/steps/app-config.ts +75 -0
  293. package/src/steps/dashboard.ts +73 -0
  294. package/src/steps/failure-dashboard.ts +38 -0
  295. package/src/steps/index.ts +2 -0
  296. package/src/steps/steps.test.ts +216 -0
  297. package/src/tool-names.ts +26 -0
  298. package/src/tools/cluster-compare.ts +28 -0
  299. package/src/tools/element.ts +35 -0
  300. package/src/tools/engine-compare.test.ts +210 -0
  301. package/src/tools/engine-compare.ts +30 -0
  302. package/src/tools/engine-landscape.test.ts +138 -0
  303. package/src/tools/engine-landscape.ts +21 -0
  304. package/src/tools/failures.ts +19 -0
  305. package/src/tools/health.test.ts +156 -0
  306. package/src/tools/health.ts +29 -0
  307. package/src/tools/index.ts +44 -0
  308. package/src/tools/performance.test.ts +210 -0
  309. package/src/tools/performance.ts +38 -0
  310. package/src/tools/version-compare.test.ts +97 -0
  311. package/src/tools/version-compare.ts +28 -0
  312. package/src/toolsets.test.ts +107 -0
  313. package/src/toolsets.ts +56 -0
  314. package/src/view-title.test.ts +123 -0
  315. package/src/widget-tools/comparisons.test.ts +170 -0
  316. package/src/widget-tools/comparisons.ts +229 -0
  317. package/src/widget-tools/shared.ts +67 -0
  318. package/src/widget-tools.test.ts +306 -0
  319. package/src/widget-tools.ts +375 -0
  320. package/src/widgets/analytics-dashboard/activity-bottleneck-table.tsx +179 -0
  321. package/src/widgets/analytics-dashboard/definition-breakdown.tsx +108 -0
  322. package/src/widgets/analytics-dashboard/execution-performance-kpi.tsx +94 -0
  323. package/src/widgets/analytics-dashboard/execution-summary-kpi.tsx +122 -0
  324. package/src/widgets/analytics-dashboard/lib.ts +43 -0
  325. package/src/widgets/bpmn-heatmap.test.tsx +75 -0
  326. package/src/widgets/bpmn-heatmap.tsx +127 -0
  327. package/src/widgets/catalogue-sync.test.ts +16 -0
  328. package/src/widgets/cluster-compare.tsx +148 -0
  329. package/src/widgets/comparison-shared.test.ts +237 -0
  330. package/src/widgets/comparison-shared.tsx +371 -0
  331. package/src/widgets/comparison-widgets.test.tsx +307 -0
  332. package/src/widgets/dashboard-widgets.test.tsx +397 -0
  333. package/src/widgets/engine-compare.tsx +87 -0
  334. package/src/widgets/engine-landscape.tsx +352 -0
  335. package/src/widgets/failure-dashboard/error-patterns-table.tsx +122 -0
  336. package/src/widgets/failure-dashboard/failure-rate-table.test.ts +80 -0
  337. package/src/widgets/failure-dashboard/failure-rate-table.tsx +170 -0
  338. package/src/widgets/failure-dashboard/lib.ts +32 -0
  339. package/src/widgets/failure-dashboard/summary-kpi.tsx +122 -0
  340. package/src/widgets/failure-landscape-widgets.test.tsx +393 -0
  341. package/src/widgets/figure.tsx +79 -0
  342. package/src/widgets/hand-off.test.ts +126 -0
  343. package/src/widgets/hand-off.ts +102 -0
  344. package/src/widgets/index.ts +124 -0
  345. package/src/widgets/model-descriptions.dashboard.test.ts +280 -0
  346. package/src/widgets/model-descriptions.test.ts +251 -0
  347. package/src/widgets/model-descriptions.ts +428 -0
  348. package/src/widgets/names.test.ts +32 -0
  349. package/src/widgets/names.ts +39 -0
  350. package/src/widgets/query-gate.test.tsx +57 -0
  351. package/src/widgets/query-gate.tsx +50 -0
  352. package/src/widgets/settings-section.test.tsx +174 -0
  353. package/src/widgets/settings-section.tsx +244 -0
  354. package/src/widgets/version-compare.test.ts +68 -0
  355. package/src/widgets/version-compare.tsx +96 -0
  356. package/src/widgets/view-meta.ts +18 -0
  357. package/src/widgets/widget-render.test-support.tsx +103 -0
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { translator } from "../messages/index.js"
3
+ import { activityTypeKey, activityTypeLabel } from "./names.js"
4
+
5
+ describe("activity types in words", () => {
6
+ it.each([
7
+ ["serviceTask", "aActivityType.serviceTask"],
8
+ ["callActivity", "aActivityType.callActivity"],
9
+ ["exclusiveGateway", "aActivityType.exclusiveGateway"],
10
+ ["startEvent", "aActivityType.startEvent"],
11
+ ["startTimerEvent", "aActivityType.startEvent"],
12
+ ["messageStartEvent", "aActivityType.startEvent"],
13
+ ["noneEndEvent", "aActivityType.endEvent"],
14
+ ["errorEndEvent", "aActivityType.endEvent"],
15
+ ["endEvent", "aActivityType.endEvent"],
16
+ ["boundaryError", "aActivityType.boundaryEvent"],
17
+ ["boundaryTimer", "aActivityType.boundaryEvent"],
18
+ ["intermediateTimer", "aActivityType.intermediateEvent"],
19
+ ["intermediateMessageThrowEvent", "aActivityType.intermediateEvent"],
20
+ ])("%s reads as %s", (type, key) => {
21
+ expect(activityTypeKey(type)).toBe(key)
22
+ })
23
+
24
+ it("leaves a type it does not know as the engine wrote it", () => {
25
+ expect(activityTypeKey("multiInstanceBody")).toBeNull()
26
+ expect(activityTypeKey("")).toBeNull()
27
+ const t = (key: string) => translator("de", key)
28
+ expect(activityTypeLabel(t, "multiInstanceBody")).toBe("multiInstanceBody")
29
+ expect(activityTypeLabel(t, "userTask")).toBe("User-Task")
30
+ expect(activityTypeLabel((key) => translator("en", key), "noneEndEvent")).toBe("End event")
31
+ })
32
+ })
@@ -0,0 +1,39 @@
1
+ import type { T } from "../messages/use-t.js"
2
+
3
+ /**
4
+ * The engine's activity types (`activity_type`, the engine's own names such
5
+ * as `noneEndEvent` or `boundaryError`) folded into the BPMN element a reader
6
+ * knows. A type it does not know stays as the engine wrote it.
7
+ */
8
+ const EXACT_TYPES = new Set([
9
+ "serviceTask",
10
+ "userTask",
11
+ "scriptTask",
12
+ "sendTask",
13
+ "receiveTask",
14
+ "businessRuleTask",
15
+ "manualTask",
16
+ "task",
17
+ "callActivity",
18
+ "subProcess",
19
+ "exclusiveGateway",
20
+ "parallelGateway",
21
+ "inclusiveGateway",
22
+ "eventBasedGateway",
23
+ ])
24
+
25
+ /** The catalog key of an activity type's words, or null for a type without one. */
26
+ export function activityTypeKey(type: string): string | null {
27
+ if (EXACT_TYPES.has(type)) return `aActivityType.${type}`
28
+ if (/^boundary/.test(type)) return "aActivityType.boundaryEvent"
29
+ if (/^intermediate/.test(type)) return "aActivityType.intermediateEvent"
30
+ if (/^start|StartEvent$/.test(type)) return "aActivityType.startEvent"
31
+ if (/^endEvent$|EndEvent$/.test(type)) return "aActivityType.endEvent"
32
+ return null
33
+ }
34
+
35
+ /** An activity type in the view's words ("Service-Task", "Endereignis"), or as the engine wrote it. */
36
+ export function activityTypeLabel(t: T, type: string): string {
37
+ const key = activityTypeKey(type)
38
+ return key ? t(key) : type
39
+ }
@@ -0,0 +1,57 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest"
3
+ import { cleanup, fireEvent, render, screen } from "@testing-library/react"
4
+ import { LocaleProvider } from "@miragon/mcp-toolkit-ui"
5
+ import { QueryGate } from "./query-gate.js"
6
+
7
+ afterEach(cleanup)
8
+
9
+ /**
10
+ * The split dashboard widgets' self-fetch gate: a failed query is never a
11
+ * dead end (U1). The kit words a Prometheus failure itself; the gate's
12
+ * own title heads only one it cannot classify.
13
+ */
14
+ describe("QueryGate", () => {
15
+ function renderGate(error: Error, refetch = vi.fn()) {
16
+ render(
17
+ <LocaleProvider locale="de">
18
+ <QueryGate
19
+ initialData={null}
20
+ query={{ data: undefined, isError: true, error, refetch }}
21
+ skeleton={<div />}
22
+ >
23
+ {() => <p>data</p>}
24
+ </QueryGate>
25
+ </LocaleProvider>,
26
+ )
27
+ return refetch
28
+ }
29
+
30
+ it("says Prometheus is unreachable and what to do, and retries the query", () => {
31
+ const refetch = renderGate(new Error("Prometheus unreachable (ECONNREFUSED)"))
32
+ expect(screen.getByRole("heading", { name: "Prometheus ist nicht erreichbar" })).toBeTruthy()
33
+ expect(screen.getByText("Prüf, ob Prometheus läuft und erreichbar ist.")).toBeTruthy()
34
+ fireEvent.click(screen.getByRole("button", { name: "Erneut versuchen" }))
35
+ expect(refetch).toHaveBeenCalledTimes(1)
36
+ })
37
+
38
+ it("heads a failure the kit cannot classify with the module's own words", () => {
39
+ renderGate(new Error("odd"))
40
+ expect(
41
+ screen.getByRole("heading", { name: "Die Kennzahlen konnten nicht geladen werden." }),
42
+ ).toBeTruthy()
43
+ })
44
+
45
+ it("renders its children once data exists", () => {
46
+ render(
47
+ <QueryGate
48
+ initialData={{ n: 1 }}
49
+ query={{ data: undefined, isError: false, refetch: vi.fn() }}
50
+ skeleton={<div />}
51
+ >
52
+ {(data) => <p>n = {data.n}</p>}
53
+ </QueryGate>,
54
+ )
55
+ expect(screen.getByText("n = 1")).toBeTruthy()
56
+ })
57
+ })
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from "react"
2
+ import { QueryFallback, WidgetShell } from "@miragon/pulse-widget-shell/widgets"
3
+ import { useT } from "../messages/use-t.js"
4
+
5
+ /** The slice of a self-fetch query result the gate needs (tanstack-shaped). */
6
+ interface GateQuery<T> {
7
+ data: T | null | undefined
8
+ isError: boolean
9
+ error?: unknown
10
+ /** Re-runs the query: the error state's retry (a failed load is never a dead end). */
11
+ refetch: () => unknown
12
+ }
13
+
14
+ /**
15
+ * Shared self-fetch gate for the split dashboard widgets: resolves pipeline
16
+ * data against the fallback query and renders the loading/error shell until
17
+ * data exists. `children` is a render prop so the happy path only runs with
18
+ * resolved data; `header` keeps a widget's static header visible while loading.
19
+ */
20
+ export function QueryGate<T>({
21
+ initialData,
22
+ query,
23
+ skeleton,
24
+ header,
25
+ children,
26
+ }: {
27
+ initialData: T | null
28
+ query: GateQuery<T>
29
+ skeleton: ReactNode
30
+ header?: ReactNode
31
+ children: (data: T) => ReactNode
32
+ }) {
33
+ const t = useT()
34
+ const data = initialData ?? query.data ?? null
35
+ if (!data) {
36
+ return (
37
+ <WidgetShell>
38
+ {header}
39
+ <QueryFallback
40
+ isError={query.isError}
41
+ error={query.error}
42
+ errorTitle={t("aCommon.loadError")}
43
+ onRetry={() => void query.refetch()}
44
+ skeleton={skeleton}
45
+ />
46
+ </WidgetShell>
47
+ )
48
+ }
49
+ return <>{children(data)}</>
50
+ }
@@ -0,0 +1,174 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeAll, describe, expect, it } from "vitest"
3
+ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
4
+ import { LocaleProvider, queryClient, ToastProvider } from "@miragon/mcp-toolkit-ui"
5
+ import { WidgetFixtureHost } from "@miragon/mcp-toolkit-ui/app"
6
+ import { ANALYTICS_SAVE_SETTINGS, ANALYTICS_SETTINGS_DATA } from "../tool-names.js"
7
+ import { AnalyticsSettingsWidget, parseMinBucketSize } from "./settings-section.js"
8
+
9
+ /**
10
+ * The settings section's own error state: when its feed fails it says the
11
+ * SETTINGS could not be loaded, not the figures a data view loads.
12
+ */
13
+
14
+ beforeAll(() => {
15
+ // One attempt: the error state is what this suite reads, not the retries.
16
+ queryClient.setQueryDefaults(["analytics:settings"], { retry: false })
17
+ })
18
+ afterEach(() => {
19
+ cleanup()
20
+ queryClient.clear()
21
+ })
22
+
23
+ /** The section as the settings page mounts it: no pipeline data, it fetches its feed. */
24
+ const Section = () => <AnalyticsSettingsWidget />
25
+
26
+ function renderIn(language: "de" | "en") {
27
+ return render(
28
+ <LocaleProvider locale={language}>
29
+ <WidgetFixtureHost
30
+ widget={Section}
31
+ tools={{
32
+ [ANALYTICS_SETTINGS_DATA]: () => {
33
+ throw new Error("profile store unavailable")
34
+ },
35
+ }}
36
+ />
37
+ </LocaleProvider>,
38
+ )
39
+ }
40
+
41
+ describe("the analytics settings section when its feed fails", () => {
42
+ // The kit's error state (U1): the section's own title for a failure the
43
+ // kit cannot classify, what you can do, the raw cause under Details, Retry.
44
+ it("says the settings could not be loaded, and what you can do (de)", async () => {
45
+ renderIn("de")
46
+ expect(
47
+ await screen.findByRole("heading", {
48
+ name: "Die Einstellungen konnten nicht geladen werden.",
49
+ }),
50
+ ).toBeTruthy()
51
+ expect(
52
+ screen.getByText("Versuch es noch mal. Klappt es wieder nicht, frag im Chat nach."),
53
+ ).toBeTruthy()
54
+ expect(screen.getByText("profile store unavailable").closest("details")).toBeTruthy()
55
+ expect(screen.getByRole("button", { name: "Erneut versuchen" })).toBeTruthy()
56
+ expect(screen.queryByText(/Kennzahlen/)).toBeNull()
57
+ })
58
+
59
+ it("says the same in English", async () => {
60
+ renderIn("en")
61
+ expect(
62
+ await screen.findByRole("heading", { name: "Could not load the settings." }),
63
+ ).toBeTruthy()
64
+ expect(screen.getByText("Try again. If it fails again, ask in the chat.")).toBeTruthy()
65
+ expect(screen.getByRole("button", { name: "Try again" })).toBeTruthy()
66
+ expect(screen.queryByText(/figures/)).toBeNull()
67
+ })
68
+ })
69
+
70
+ describe("the analytics settings section when it saves (U9: a save is never silent)", () => {
71
+ const VIEW = { settings: { defaultPeriod: "7d", minBucketSize: 5 }, canSave: true }
72
+
73
+ function renderSaving(save: () => unknown) {
74
+ return render(
75
+ <LocaleProvider locale="en">
76
+ <ToastProvider>
77
+ <WidgetFixtureHost
78
+ widget={Section}
79
+ tools={{ [ANALYTICS_SETTINGS_DATA]: () => VIEW, [ANALYTICS_SAVE_SETTINGS]: save }}
80
+ />
81
+ </ToastProvider>
82
+ </LocaleProvider>,
83
+ )
84
+ }
85
+
86
+ const toast = () => document.querySelector(`[data-slot="toast"]`)
87
+
88
+ it("confirms the save in a toast — the polite live region a screen reader hears", async () => {
89
+ renderSaving(() => ({ success: true }))
90
+ fireEvent.click(await screen.findByRole("button", { name: "Save" }))
91
+ await waitFor(() => expect(toast()?.textContent).toContain("Settings saved"))
92
+ expect(toast()?.getAttribute("data-tone")).toBe("success")
93
+ expect(toast()?.closest(`[aria-live="polite"]`)).toBeTruthy()
94
+ })
95
+
96
+ it("says why a save failed, in a danger toast", async () => {
97
+ renderSaving(() => {
98
+ throw new Error("Tool analytics_save_settings not found")
99
+ })
100
+ fireEvent.click(await screen.findByRole("button", { name: "Save" }))
101
+ await waitFor(() => expect(toast()?.textContent).toContain("Couldn't save the settings"))
102
+ expect(toast()?.getAttribute("data-tone")).toBe("danger")
103
+ expect(toast()?.textContent).toContain("This data isn't available on this server.")
104
+ })
105
+
106
+ // U25: an entry that is no valid size is said to be wrong, never turned into 1.
107
+ it.each(["abc", "0", "2.5", " ", "-3"])("refuses %j inline and saves nothing", async (entry) => {
108
+ const saves: unknown[] = []
109
+ renderSaving((args?: unknown) => {
110
+ saves.push(args)
111
+ return { success: true }
112
+ })
113
+ const field = await screen.findByRole("textbox", { name: "Minimum size for comparisons" })
114
+ fireEvent.change(field, { target: { value: entry } })
115
+ expect(field.getAttribute("aria-invalid")).toBe("true")
116
+ const error = screen.getByText("Enter a whole number of 1 or more.")
117
+ expect(field.getAttribute("aria-describedby")).toContain(error.closest("[id]")!.id)
118
+ fireEvent.click(screen.getByRole("button", { name: "Save" }))
119
+ await waitFor(() => expect(document.activeElement).toBe(field))
120
+ expect(saves).toEqual([])
121
+ expect(toast()).toBeNull()
122
+ })
123
+
124
+ it("saves a valid size as the number you typed", async () => {
125
+ const saves: unknown[] = []
126
+ renderSaving((args?: unknown) => {
127
+ saves.push(args)
128
+ return { success: true }
129
+ })
130
+ const field = await screen.findByRole("textbox", { name: "Minimum size for comparisons" })
131
+ fireEvent.change(field, { target: { value: " 25 " } })
132
+ expect(field.getAttribute("aria-invalid")).toBeNull()
133
+ fireEvent.click(screen.getByRole("button", { name: "Save" }))
134
+ await waitFor(() => expect(saves).toEqual([{ defaultPeriod: "7d", minBucketSize: 25 }]))
135
+ })
136
+ })
137
+
138
+ describe("the analytics settings section without the right to save (U25)", () => {
139
+ const READ_ONLY = { settings: { defaultPeriod: "7d", minBucketSize: 5 }, canSave: false }
140
+
141
+ it("says why first, above the fields it cannot change, and offers no Save (de)", async () => {
142
+ render(
143
+ <LocaleProvider locale="de">
144
+ <WidgetFixtureHost
145
+ widget={Section}
146
+ tools={{ [ANALYTICS_SETTINGS_DATA]: () => READ_ONLY }}
147
+ />
148
+ </LocaleProvider>,
149
+ )
150
+ const notice = await screen.findByText("Nur lesen")
151
+ expect(
152
+ screen.getByText(
153
+ "Diese Verbindung kann keine Einstellungen speichern. Ändern kannst du sie, wenn du dich über eine Verbindung mit dem Analytics-Toolset standard anmeldest.",
154
+ ),
155
+ ).toBeTruthy()
156
+ const field = screen.getByRole("textbox", { name: "Mindestgröße für Vergleiche" })
157
+ expect(field.hasAttribute("disabled")).toBe(true)
158
+ // The reason comes before the fields in reading order.
159
+ expect(notice.compareDocumentPosition(field) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
160
+ expect(screen.queryByRole("button", { name: "Speichern" })).toBeNull()
161
+ // A notice in the first paint, not an announcement.
162
+ expect(screen.queryByRole("alert")).toBeNull()
163
+ })
164
+ })
165
+
166
+ describe("parseMinBucketSize", () => {
167
+ it("takes a whole number of 1 or more, nothing else", () => {
168
+ expect(parseMinBucketSize("10")).toBe(10)
169
+ expect(parseMinBucketSize(" 3 ")).toBe(3)
170
+ for (const raw of ["", "0", "-1", "1.5", "1e3", "abc", "99999999999999999999"]) {
171
+ expect(parseMinBucketSize(raw), raw).toBeNull()
172
+ }
173
+ })
174
+ })
@@ -0,0 +1,244 @@
1
+ import { useRef, useState } from "react"
2
+ import {
3
+ Alert,
4
+ AlertDescription,
5
+ Button,
6
+ ErrorState,
7
+ Skeleton,
8
+ useToolMutation,
9
+ useToolQuery,
10
+ } from "@miragon/mcp-toolkit-ui"
11
+ import { HostModelContext } from "@miragon/mcp-toolkit-ui/app"
12
+ import { Lock } from "lucide-react"
13
+ import {
14
+ NativeSelect,
15
+ QueryFallback,
16
+ SettingsCard,
17
+ SettingsField,
18
+ SettingsInput,
19
+ WidgetShell,
20
+ formatPeriod,
21
+ modelContextText,
22
+ useActionFeedback,
23
+ useResetOnChange,
24
+ } from "@miragon/pulse-widget-shell/widgets"
25
+ // The leaf, not the barrel: the barrel's value graph carries every tool's zod
26
+ // input schema, which a widget never needs (#344 K50).
27
+ import { PERIODS, type Period } from "@miragon/pulse-analytics-client/prometheus"
28
+ import { ANALYTICS_SAVE_SETTINGS, ANALYTICS_SETTINGS_DATA } from "../tool-names.js"
29
+ import { useT } from "../messages/use-t.js"
30
+ import { describeAnalyticsSettings } from "./model-descriptions.js"
31
+
32
+ /**
33
+ * Wire shape of the `analytics_settings_data` feed / `analytics_show_settings`
34
+ * data (see `settings-tools.ts`). Declared structurally here so the widget
35
+ * bundle stays free of server-side imports.
36
+ */
37
+ export interface AnalyticsSettingsViewData {
38
+ settings: { defaultPeriod: Period; minBucketSize: number }
39
+ canSave: boolean
40
+ }
41
+
42
+ /**
43
+ * The analytics module's settings section — one card the settings page
44
+ * composes next to the camunda7 profile panel. Self-fetches its app-only feed;
45
+ * when the feed TOOL is missing (analytics module inactive) the section hides
46
+ * itself, mirroring the tier-2 graceful degradation of
47
+ * `process-incidents/flow.tsx`. Any other failure (profile store outage,
48
+ * network) stays visible as an error state instead of silently vanishing.
49
+ */
50
+ export function AnalyticsSettingsWidget({
51
+ data: initialData = null,
52
+ }: {
53
+ data?: AnalyticsSettingsViewData | null
54
+ }) {
55
+ const t = useT()
56
+ const query = useToolQuery<AnalyticsSettingsViewData>(
57
+ ["analytics:settings"],
58
+ ANALYTICS_SETTINGS_DATA,
59
+ {},
60
+ { enabled: !initialData },
61
+ )
62
+ const view = initialData ?? query.data ?? null
63
+
64
+ if (!view) {
65
+ if (query.isError) {
66
+ const message = query.error instanceof Error ? query.error.message : String(query.error)
67
+ // The unknown-tool rejection = module inactive → hide the section.
68
+ if (message.includes(ANALYTICS_SETTINGS_DATA) && /not found/i.test(message)) return null
69
+ }
70
+ return (
71
+ <WidgetShell>
72
+ <QueryFallback
73
+ isError={query.isError}
74
+ error={query.error}
75
+ errorTitle={t("aSettings.loadError")}
76
+ onRetry={() => void query.refetch()}
77
+ skeleton={<Skeleton className="h-24 w-full rounded-md" />}
78
+ />
79
+ </WidgetShell>
80
+ )
81
+ }
82
+
83
+ return (
84
+ <WidgetShell>
85
+ <SettingsPanel view={view} />
86
+ </WidgetShell>
87
+ )
88
+ }
89
+
90
+ /** Raw form state — free typing, checked as you type and once more on save. */
91
+ interface FormState {
92
+ defaultPeriod: Period
93
+ minBucketSize: string
94
+ }
95
+
96
+ /**
97
+ * The minimum size as the save tool takes it — a whole number of 1 or more —
98
+ * or null for anything else ("abc", "0", "2.5", ""). Never clamped: an entry
99
+ * that is not a valid size is said to be wrong, not silently turned into 1.
100
+ */
101
+ export function parseMinBucketSize(raw: string): number | null {
102
+ const text = raw.trim()
103
+ if (!/^\d+$/.test(text)) return null
104
+ const value = Number(text)
105
+ return Number.isSafeInteger(value) && value >= 1 ? value : null
106
+ }
107
+
108
+ /**
109
+ * Why the section cannot save, above everything else (U25): the reader meets
110
+ * the reason before the fields it disables, and the way to change them.
111
+ */
112
+ function ReadOnlyNotice() {
113
+ const t = useT()
114
+ return (
115
+ <ErrorState
116
+ variant="inline"
117
+ tone="info"
118
+ role="none"
119
+ icon={Lock}
120
+ title={t("aSettings.readOnlyTitle")}
121
+ description={t("aSettings.readOnly")}
122
+ />
123
+ )
124
+ }
125
+
126
+ function SettingsPanel({ view }: { view: AnalyticsSettingsViewData }) {
127
+ const t = useT()
128
+ // invalidateKeys: after a save the feed refetches, so a remounted section
129
+ // seeds from the saved server truth instead of the stale cache entry.
130
+ const save = useToolMutation(ANALYTICS_SAVE_SETTINGS, {
131
+ invalidateKeys: [["analytics:settings"]],
132
+ })
133
+ const [form, setForm] = useState<FormState>({
134
+ defaultPeriod: view.settings.defaultPeriod,
135
+ minBucketSize: String(view.settings.minBucketSize),
136
+ })
137
+ const bucketRef = useRef<HTMLInputElement>(null)
138
+ // The save confirms in a toast (the toolkit's polite live region), a
139
+ // failure says why there too — and stays in the panel next to the entries.
140
+ const feedback = useActionFeedback()
141
+ const minBucketSize = parseMinBucketSize(form.minBucketSize)
142
+
143
+ // Re-sync the baseline when server truth changes (feed refetch after save,
144
+ // refreshCockpitData from the camunda7 panel, another session's write) —
145
+ // mirrors user-profile.tsx; the feed carries no updatedAt, so the values
146
+ // themselves are the stamp.
147
+ const settingsStamp = `${view.settings.defaultPeriod}|${view.settings.minBucketSize}`
148
+ useResetOnChange(settingsStamp, () =>
149
+ setForm({
150
+ defaultPeriod: view.settings.defaultPeriod,
151
+ minBucketSize: String(view.settings.minBucketSize),
152
+ }),
153
+ )
154
+
155
+ function handleSave() {
156
+ // An entry that is no valid size is never saved, and never corrected
157
+ // behind your back: the field says what is wrong, the focus goes there.
158
+ if (minBucketSize === null) {
159
+ bucketRef.current?.focus()
160
+ return
161
+ }
162
+ save.mutate(
163
+ { defaultPeriod: form.defaultPeriod, minBucketSize },
164
+ {
165
+ onSuccess: () => feedback.succeeded(t("aSettings.savedToast")),
166
+ onError: (error) => feedback.failed(t("aSettings.saveFailedToast"), error),
167
+ },
168
+ )
169
+ }
170
+
171
+ return (
172
+ /* Inline (not via adaptDataWidget): the cockpit settings tab renders this
173
+ widget without pipeline data, and only the mounted component knows the
174
+ self-fetched view. HostModelContext renders its children unchanged. */
175
+ <HostModelContext content={modelContextText(describeAnalyticsSettings(view))}>
176
+ <div className="flex flex-col gap-4">
177
+ <div className="flex flex-wrap items-start justify-between gap-3">
178
+ <div>
179
+ <h2 className="text-section">{t("aSettings.heading")}</h2>
180
+ <p className="text-muted-foreground text-small mt-1">{t("aSettings.subtitle")}</p>
181
+ </div>
182
+ {view.canSave && (
183
+ <Button size="sm" onClick={handleSave} disabled={save.isPending}>
184
+ {save.isPending ? t("aSettings.saving") : t("aSettings.save")}
185
+ </Button>
186
+ )}
187
+ </div>
188
+
189
+ {!view.canSave && <ReadOnlyNotice />}
190
+
191
+ {save.isError && (
192
+ <Alert variant="danger">
193
+ <AlertDescription>
194
+ {save.error?.message
195
+ ? t("aSettings.saveFailed", { reason: save.error.message })
196
+ : t("aSettings.saveError")}
197
+ </AlertDescription>
198
+ </Alert>
199
+ )}
200
+ </div>
201
+
202
+ <div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
203
+ <SettingsCard title={t("aSettings.section")}>
204
+ <SettingsField label={t("aSettings.period")} help={t("aSettings.period.help")}>
205
+ <NativeSelect
206
+ value={form.defaultPeriod}
207
+ disabled={!view.canSave}
208
+ onChange={(e) =>
209
+ setForm((f) => ({
210
+ ...f,
211
+ defaultPeriod: (PERIODS as readonly string[]).includes(e.target.value)
212
+ ? (e.target.value as Period)
213
+ : f.defaultPeriod,
214
+ }))
215
+ }
216
+ >
217
+ {PERIODS.map((p) => (
218
+ <option key={p} value={p}>
219
+ {formatPeriod(p)}
220
+ </option>
221
+ ))}
222
+ </NativeSelect>
223
+ </SettingsField>
224
+ <SettingsField
225
+ label={t("aSettings.minBucket")}
226
+ help={t("aSettings.minBucket.help")}
227
+ error={minBucketSize === null ? t("aSettings.minBucket.invalid") : undefined}
228
+ >
229
+ {/* Text, not `type="number"`: a browser empties a number field on a
230
+ letter, and the entry you typed must stay to be corrected. */}
231
+ <SettingsInput
232
+ ref={bucketRef}
233
+ type="text"
234
+ inputMode="numeric"
235
+ disabled={!view.canSave}
236
+ value={form.minBucketSize}
237
+ onChange={(e) => setForm((f) => ({ ...f, minBucketSize: e.target.value }))}
238
+ />
239
+ </SettingsField>
240
+ </SettingsCard>
241
+ </div>
242
+ </HostModelContext>
243
+ )
244
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { VersionCompareKpi, VersionCompareResult } from "@miragon/pulse-analytics-client"
3
+ import { versionCompareHandOff } from "./version-compare.js"
4
+ import { ANALYTICS_ONLY_SURFACE, bindHandOff } from "./hand-off.js"
5
+
6
+ const versionKpi = (version: number, incidentRatePct: number): VersionCompareKpi => ({
7
+ version,
8
+ bucket: version === 1 ? "versionA" : "versionB",
9
+ instance_count: 100,
10
+ completed_count: 90,
11
+ incident_count: incidentRatePct,
12
+ incident_rate_pct: incidentRatePct,
13
+ element_incident_count: null,
14
+ element_incident_rate_pct: null,
15
+ avg_duration_sec: 10,
16
+ p95_duration_sec: 20,
17
+ })
18
+
19
+ const result: VersionCompareResult = {
20
+ engines: ["prod-a"],
21
+ processDefinitionKey: "order",
22
+ versionA: 1,
23
+ versionB: 2,
24
+ windowDays: 14,
25
+ minBucketSize: 10,
26
+ suppressed: false,
27
+ kpis: [versionKpi(1, 2), versionKpi(2, 2.5)],
28
+ delta: {
29
+ started_per_day_delta_pct: 0,
30
+ incident_rate_delta_pp: 0.5,
31
+ element_incident_rate_delta_pp: null,
32
+ avg_duration_delta_pct: 25,
33
+ p95_duration_delta_pct: 40,
34
+ },
35
+ }
36
+
37
+ const prompt = (data: VersionCompareResult) =>
38
+ bindHandOff("en", ANALYTICS_ONLY_SURFACE).ask(versionCompareHandOff(data))!
39
+
40
+ describe("versionCompareHandOff", () => {
41
+ it("re-runs the process-wide comparison on the engines on screen — never an element scope", () => {
42
+ const text = prompt(result)
43
+
44
+ expect(text).toContain(
45
+ 'Ids: engine="prod-a", processDefinitionKey="order", versionA=1, versionB=2, windowDays=14\n',
46
+ )
47
+ // The tool takes no element scope (#336) — the hand-off must not suggest one.
48
+ expect(text).not.toContain("activityId")
49
+ })
50
+
51
+ it("carries the measured deltas, the incident rate per version included", () => {
52
+ const text = prompt(result)
53
+ expect(text).toContain("incidentRateDeltaPp=0.5")
54
+ expect(text).toContain("avgDurationDeltaPct=25")
55
+ expect(text).not.toContain("elementIncidentRateDeltaPp")
56
+ })
57
+
58
+ it("leaves a null delta out instead of sending it as 0", () => {
59
+ const text = prompt({ ...result, delta: { ...result.delta, p95_duration_delta_pct: null } })
60
+ expect(text).not.toContain("p95DurationDeltaPct")
61
+ })
62
+
63
+ it("names the confirming comparison and the element ranking", () => {
64
+ expect(prompt(result)).toContain(
65
+ "Tools: analytics_version_compare, analytics_element_bottleneck",
66
+ )
67
+ })
68
+ })