lhcb-ntuple-wizard-test 2.1.0 → 2.2.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 (178) hide show
  1. package/dist/components/AddTupleToolButton.d.ts +1 -0
  2. package/dist/components/AddTupleToolButton.js +16 -0
  3. package/dist/components/BookkeepingPathDropdown.d.ts +1 -1
  4. package/dist/components/BookkeepingPathDropdown.js +1 -4
  5. package/dist/components/DatasetEventTypeBadge.js +1 -1
  6. package/dist/components/DatasetInfo.js +3 -3
  7. package/dist/components/DecayCard.d.ts +1 -1
  8. package/dist/components/DecayCard.js +12 -40
  9. package/dist/components/DecayLatex.d.ts +1 -1
  10. package/dist/components/DecayLatex.js +4 -87
  11. package/dist/components/DecayListItem.js +2 -2
  12. package/dist/components/DecayTagBadge.d.ts +1 -1
  13. package/dist/components/DecayTagBadge.js +0 -3
  14. package/dist/components/DecayTreeCard.d.ts +1 -7
  15. package/dist/components/DecayTreeCard.js +13 -18
  16. package/dist/components/DecayTreeCardHeader.d.ts +2 -3
  17. package/dist/components/DecayTreeCardHeader.js +2 -2
  18. package/dist/components/DecayTreeGraph.d.ts +1 -4
  19. package/dist/components/DecayTreeGraph.js +66 -41
  20. package/dist/components/DeleteButton.d.ts +2 -1
  21. package/dist/components/DeleteButton.js +2 -2
  22. package/dist/components/DttNameInput.d.ts +9 -0
  23. package/dist/components/DttNameInput.js +43 -0
  24. package/dist/components/GuideLinkButton.d.ts +15 -0
  25. package/dist/components/GuideLinkButton.js +16 -0
  26. package/dist/components/NtupleWizard.js +1 -7
  27. package/dist/components/ParticleDropdown.d.ts +11 -1
  28. package/dist/components/ParticleDropdown.js +3 -6
  29. package/dist/components/ParticleLatex.d.ts +6 -0
  30. package/dist/components/ParticleLatex.js +13 -0
  31. package/dist/components/ParticleTagBadge.d.ts +2 -1
  32. package/dist/components/ParticleTagBadge.js +5 -7
  33. package/dist/components/ParticleTagFilters.d.ts +1 -6
  34. package/dist/components/ParticleTagFilters.js +16 -17
  35. package/dist/components/RequestButtonGroup.d.ts +1 -1
  36. package/dist/components/RequestButtonGroup.js +0 -3
  37. package/dist/components/RequestRow.d.ts +1 -1
  38. package/dist/components/RequestRow.js +4 -9
  39. package/dist/components/StrippingLineDropdown.js +14 -16
  40. package/dist/components/StrippingLineInfo.d.ts +5 -5
  41. package/dist/components/StrippingLineInfo.js +14 -4
  42. package/dist/components/StrippingLineInfoButton.d.ts +6 -0
  43. package/dist/components/StrippingLineInfoButton.js +7 -0
  44. package/dist/components/StrippingLineVersionBadge.d.ts +7 -0
  45. package/dist/components/{StrippingLineBadge.js → StrippingLineVersionBadge.js} +5 -5
  46. package/dist/components/StrippingLinesCountBadge.d.ts +8 -0
  47. package/dist/components/StrippingLinesCountBadge.js +11 -0
  48. package/dist/components/TagDropdown.d.ts +3 -3
  49. package/dist/components/TagDropdown.js +2 -2
  50. package/dist/components/TupleToolClassDropdown.js +11 -14
  51. package/dist/components/TupleToolDocsAccordion.d.ts +1 -1
  52. package/dist/components/TupleToolDocsAccordion.js +4 -8
  53. package/dist/components/TupleToolGroupsAccordion.d.ts +5 -0
  54. package/dist/components/TupleToolGroupsAccordion.js +31 -0
  55. package/dist/components/TupleToolLabel.d.ts +1 -1
  56. package/dist/components/TupleToolLabel.js +2 -5
  57. package/dist/components/TupleToolsAccordion.d.ts +1 -0
  58. package/dist/components/TupleToolsAccordion.js +22 -0
  59. package/dist/components/modals/AddTupleToolModal.js +3 -2
  60. package/dist/components/modals/ConfigureTupleToolModal.js +1 -1
  61. package/dist/components/modals/UploadDttConfigModal.d.ts +1 -1
  62. package/dist/components/modals/UploadDttConfigModal.js +1 -4
  63. package/dist/config.d.ts +1 -0
  64. package/dist/config.js +3 -2
  65. package/dist/models/bkPath.js +1 -1
  66. package/dist/models/dtt.d.ts +5 -2
  67. package/dist/models/dtt.js +37 -18
  68. package/dist/models/rowData.d.ts +1 -1
  69. package/dist/models/yamlFile.js +1 -1
  70. package/dist/pages/DecaySearchPage.js +4 -9
  71. package/dist/pages/DecayTreeConfigPage.js +4 -38
  72. package/dist/pages/RequestPage.js +2 -4
  73. package/dist/providers/DttProvider.d.ts +3 -3
  74. package/dist/providers/DttProvider.js +30 -55
  75. package/dist/providers/MetadataProvider.d.ts +1 -1
  76. package/dist/providers/MetadataProvider.js +11 -5
  77. package/dist/providers/RequestProvider.js +2 -2
  78. package/dist/providers/RowProvider.d.ts +2 -2
  79. package/dist/providers/RowProvider.js +10 -9
  80. package/dist/providers/RowsProvider.js +0 -3
  81. package/dist/tests/components/BookkeepingPathDropdown.test.d.ts +1 -0
  82. package/dist/tests/components/BookkeepingPathDropdown.test.js +118 -0
  83. package/dist/tests/components/DatasetInfo.test.d.ts +1 -0
  84. package/dist/tests/components/DatasetInfo.test.js +38 -0
  85. package/dist/tests/components/DecayCard.test.d.ts +1 -0
  86. package/dist/tests/components/DecayCard.test.js +115 -0
  87. package/dist/tests/components/DecayLatex.test.d.ts +1 -0
  88. package/dist/tests/components/DecayLatex.test.js +31 -0
  89. package/dist/tests/components/DecayList.test.d.ts +1 -0
  90. package/dist/tests/components/DecayList.test.js +76 -0
  91. package/dist/tests/components/DecayListItem.test.d.ts +1 -0
  92. package/dist/tests/components/DecayListItem.test.js +51 -0
  93. package/dist/tests/components/DecayTreeCard.test.d.ts +1 -0
  94. package/dist/tests/components/DecayTreeCard.test.js +119 -0
  95. package/dist/tests/components/DecayTreeGraph.test.d.ts +1 -0
  96. package/dist/tests/components/DecayTreeGraph.test.js +125 -0
  97. package/dist/tests/components/DeleteButton.test.d.ts +1 -0
  98. package/dist/tests/components/DeleteButton.test.js +45 -0
  99. package/dist/tests/components/DttNameInput.test.d.ts +1 -0
  100. package/dist/tests/components/DttNameInput.test.js +75 -0
  101. package/dist/tests/components/NtupleWizard.test.d.ts +1 -0
  102. package/dist/tests/components/NtupleWizard.test.js +57 -0
  103. package/dist/tests/components/ParticleDropdown.test.d.ts +1 -0
  104. package/dist/tests/components/ParticleDropdown.test.js +23 -0
  105. package/dist/tests/components/ParticleTagFilters.test.d.ts +1 -0
  106. package/dist/tests/components/ParticleTagFilters.test.js +87 -0
  107. package/dist/tests/components/RequestButtonGroup.test.d.ts +1 -0
  108. package/dist/tests/components/RequestButtonGroup.test.js +132 -0
  109. package/dist/tests/components/RequestRow.test.d.ts +1 -0
  110. package/dist/tests/components/RequestRow.test.js +58 -0
  111. package/dist/tests/components/StrippingLineDropdown.test.d.ts +1 -0
  112. package/dist/tests/components/StrippingLineDropdown.test.js +88 -0
  113. package/dist/tests/components/badges.test.d.ts +1 -0
  114. package/dist/tests/components/badges.test.js +120 -0
  115. package/dist/tests/components/dropdowns.test.d.ts +1 -0
  116. package/dist/tests/components/dropdowns.test.js +110 -0
  117. package/dist/tests/components/dttComponents.test.d.ts +1 -0
  118. package/dist/tests/components/dttComponents.test.js +287 -0
  119. package/dist/tests/components/formInputs.test.d.ts +1 -0
  120. package/dist/tests/components/formInputs.test.js +96 -0
  121. package/dist/tests/components/metadataComponents.test.d.ts +1 -0
  122. package/dist/tests/components/metadataComponents.test.js +137 -0
  123. package/dist/tests/components/miscComponents.test.d.ts +1 -0
  124. package/dist/tests/components/miscComponents.test.js +134 -0
  125. package/dist/tests/components/modals.test.d.ts +1 -0
  126. package/dist/tests/components/modals.test.js +554 -0
  127. package/dist/tests/components/tupleToolParams.test.d.ts +1 -0
  128. package/dist/tests/components/tupleToolParams.test.js +213 -0
  129. package/dist/tests/config.test.d.ts +1 -0
  130. package/dist/tests/config.test.js +31 -0
  131. package/dist/tests/mockSetup.d.ts +1 -0
  132. package/dist/tests/mockSetup.js +30 -0
  133. package/dist/tests/models/BkPath.test.d.ts +1 -0
  134. package/dist/tests/models/BkPath.test.js +87 -0
  135. package/dist/tests/models/Dtt.test.d.ts +1 -0
  136. package/dist/tests/models/Dtt.test.js +376 -0
  137. package/dist/tests/models/TupleTool.test.d.ts +1 -0
  138. package/dist/tests/models/TupleTool.test.js +80 -0
  139. package/dist/tests/models/YamlFile.test.d.ts +1 -0
  140. package/dist/tests/models/YamlFile.test.js +123 -0
  141. package/dist/tests/pages/DecaySearchPage.test.d.ts +1 -0
  142. package/dist/tests/pages/DecaySearchPage.test.js +228 -0
  143. package/dist/tests/pages/DecayTreeConfigPage.test.d.ts +1 -0
  144. package/dist/tests/pages/DecayTreeConfigPage.test.js +105 -0
  145. package/dist/tests/pages/RequestPage.test.d.ts +1 -0
  146. package/dist/tests/pages/RequestPage.test.js +439 -0
  147. package/dist/tests/providers/DttProvider.test.d.ts +1 -0
  148. package/dist/tests/providers/DttProvider.test.js +105 -0
  149. package/dist/tests/providers/MetadataProvider.test.d.ts +1 -0
  150. package/dist/tests/providers/MetadataProvider.test.js +129 -0
  151. package/dist/tests/providers/RequestProvider.test.d.ts +1 -0
  152. package/dist/tests/providers/RequestProvider.test.js +306 -0
  153. package/dist/tests/providers/RowProvider.test.d.ts +1 -0
  154. package/dist/tests/providers/RowProvider.test.js +110 -0
  155. package/dist/tests/providers/RowsProvider.test.d.ts +1 -0
  156. package/dist/tests/providers/RowsProvider.test.js +84 -0
  157. package/dist/tests/providers/WizardConfigProvider.test.d.ts +1 -0
  158. package/dist/tests/providers/WizardConfigProvider.test.js +36 -0
  159. package/dist/tests/setupTests.d.ts +1 -0
  160. package/dist/tests/setupTests.js +15 -0
  161. package/dist/tests/testUtils.d.ts +33 -0
  162. package/dist/tests/testUtils.js +196 -0
  163. package/dist/tests/utils/latexUtils.test.d.ts +1 -0
  164. package/dist/tests/utils/latexUtils.test.js +62 -0
  165. package/dist/tests/utils/utils.test.d.ts +1 -0
  166. package/dist/tests/utils/utils.test.js +394 -0
  167. package/dist/utils/latexUtils.d.ts +13 -0
  168. package/dist/utils/latexUtils.js +86 -0
  169. package/dist/utils/utils.d.ts +1 -0
  170. package/dist/utils/utils.js +4 -1
  171. package/package.json +16 -7
  172. package/dist/components/NumStrippingLinesBadge.d.ts +0 -8
  173. package/dist/components/NumStrippingLinesBadge.js +0 -10
  174. package/dist/components/StrippingLineBadge.d.ts +0 -7
  175. package/dist/components/TupleToolGroup.d.ts +0 -5
  176. package/dist/components/TupleToolGroup.js +0 -22
  177. package/dist/components/TupleToolList.d.ts +0 -6
  178. package/dist/components/TupleToolList.js +0 -42
@@ -0,0 +1,394 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ // Mock typia before any imports that use it
3
+ vi.mock("typia", () => ({
4
+ default: {
5
+ validate: vi.fn((value) => ({ success: true, data: value })),
6
+ },
7
+ }));
8
+ import { sanitizeFilename, processProductionFiles, downloadText, downloadZip, getControlKey, parseProductionFiles, } from "../../utils/utils";
9
+ import { YamlFile } from "../../models/yamlFile";
10
+ import { mockDecay, mockMetadata, mockToolMetadata } from "../testUtils";
11
+ // A minimal saved DTT config that matches mockDecay's descriptorTemplate
12
+ const mockSavedConfig = {
13
+ name: "DecayTreeTuple/MyTree",
14
+ descriptorTemplate: "[${head}B+ -> ${k}K+ ${mup}mu+ ${mum}mu-]CC",
15
+ inputs: ["/Event/Leptonic/Phys/BuToKJpsiee2Line/Particles"],
16
+ tools: [{ TupleToolKinematic: { Verbose: false } }],
17
+ branches: {
18
+ head: { particle: "B+", tools: [] },
19
+ k: { particle: "K+", tools: [] },
20
+ mup: { particle: "mu+", tools: [] },
21
+ mum: { particle: "mu-", tools: [] },
22
+ },
23
+ groups: {},
24
+ };
25
+ // The metadata's decay key must match the template
26
+ const metadataWithDecay = {
27
+ ...mockMetadata,
28
+ decays: {
29
+ "some-key": {
30
+ ...mockDecay,
31
+ descriptors: {
32
+ ...mockDecay.descriptors,
33
+ // Must match mockSavedConfig.descriptorTemplate
34
+ template: "[${head}B+ -> ${k}K+ ${mup}mu+ ${mum}mu-]CC",
35
+ },
36
+ },
37
+ },
38
+ tupleTools: {
39
+ applicationInfo: { Analysis: "v25r1", DaVinci: "v45r7", Phys: "v26r2" },
40
+ tupleTools: mockToolMetadata,
41
+ },
42
+ };
43
+ describe("sanitizeFilename()", () => {
44
+ it("returns the filename unchanged when safe", () => {
45
+ expect(sanitizeFilename("my-file_01.yaml")).toBe("my-file_01.yaml");
46
+ });
47
+ it("strips directory separators (forward slash)", () => {
48
+ expect(sanitizeFilename("path/to/file.yaml")).toBe("file.yaml");
49
+ });
50
+ it("strips directory separators (backslash)", () => {
51
+ expect(sanitizeFilename("path\\to\\file.yaml")).toBe("file.yaml");
52
+ });
53
+ it("removes path traversal by stripping all directory components (keeps only basename)", () => {
54
+ // sanitizeFilename takes the last path segment, so traversal is neutralised
55
+ expect(sanitizeFilename("../../etc/passwd")).toBe("passwd");
56
+ });
57
+ it("removes leading dots", () => {
58
+ expect(sanitizeFilename(".hidden")).toBe("hidden");
59
+ });
60
+ it("removes unsafe characters", () => {
61
+ expect(sanitizeFilename("file name!@#.yaml")).toBe("filename.yaml");
62
+ });
63
+ it("returns the fallback when sanitization produces an empty string", () => {
64
+ expect(sanitizeFilename("!!!")).toBe("file");
65
+ });
66
+ it("returns a custom fallback when provided", () => {
67
+ expect(sanitizeFilename("!!!", "default.yaml")).toBe("default.yaml");
68
+ });
69
+ it("allows dots, hyphens, underscores in filenames", () => {
70
+ expect(sanitizeFilename("my-file_name.tar.gz")).toBe("my-file_name.tar.gz");
71
+ });
72
+ it("handles empty string input with fallback", () => {
73
+ expect(sanitizeFilename("")).toBe("file");
74
+ });
75
+ });
76
+ describe("processProductionFiles()", () => {
77
+ it("returns rows for each saved config", () => {
78
+ const result = processProductionFiles(metadataWithDecay, [mockSavedConfig], null);
79
+ expect(result).not.toBeTypeOf("string");
80
+ if (typeof result === "string")
81
+ return;
82
+ expect(result.rows).toHaveLength(1);
83
+ });
84
+ it("returns an error string when decay template is not found in metadata", () => {
85
+ const badConfig = {
86
+ ...mockSavedConfig,
87
+ descriptorTemplate: "UnknownDecay",
88
+ };
89
+ const result = processProductionFiles(metadataWithDecay, [badConfig], null);
90
+ expect(result).toBeTypeOf("string");
91
+ expect(result).toContain("Unknown decay");
92
+ });
93
+ it("returns an error string when config has no inputs", () => {
94
+ const badConfig = { ...mockSavedConfig, inputs: undefined };
95
+ const result = processProductionFiles(metadataWithDecay, [badConfig], null);
96
+ expect(result).toBeTypeOf("string");
97
+ expect(result).toContain("No inputs defined");
98
+ });
99
+ it("parses the stripping line from the input path", () => {
100
+ const result = processProductionFiles(metadataWithDecay, [mockSavedConfig], null);
101
+ if (typeof result === "string")
102
+ throw new Error(result);
103
+ const row = result.rows[0];
104
+ expect(row.line?.line).toBe("StrippingBuToKJpsiee2Line");
105
+ expect(row.line?.stream).toBe("Leptonic");
106
+ });
107
+ it("adds BK paths from info.yaml to rows", () => {
108
+ const VALID_PATH = "/LHCb/Collision16/Beam6500GeV-VeloClosed-MagDown/Real Data/Reco16/Stripping28r2/90000000/DIMUON.DST";
109
+ const infoYaml = {
110
+ defaults: {
111
+ application: "DaVinci/v45r7",
112
+ wg: "OpenData",
113
+ inform: [],
114
+ automatically_configure: true,
115
+ output: "DVNtuple.root",
116
+ },
117
+ job0: {
118
+ input: { bk_query: VALID_PATH },
119
+ options: ["MyTree"],
120
+ },
121
+ };
122
+ const result = processProductionFiles(metadataWithDecay, [mockSavedConfig], infoYaml);
123
+ if (typeof result === "string")
124
+ throw new Error(result);
125
+ expect(result.rows[0].paths).toContain(VALID_PATH);
126
+ });
127
+ it("extracts email from info.yaml defaults.inform", () => {
128
+ const infoYaml = {
129
+ defaults: {
130
+ application: "DaVinci/v45r7",
131
+ wg: "OpenData",
132
+ inform: ["user@cern.ch"],
133
+ automatically_configure: true,
134
+ output: "DVNtuple.root",
135
+ },
136
+ };
137
+ const result = processProductionFiles(metadataWithDecay, [mockSavedConfig], infoYaml);
138
+ if (typeof result === "string")
139
+ throw new Error(result);
140
+ expect(result.emails).toContain("user@cern.ch");
141
+ });
142
+ it("returns empty emails list when inform is empty", () => {
143
+ const result = processProductionFiles(metadataWithDecay, [mockSavedConfig], null);
144
+ if (typeof result === "string")
145
+ throw new Error(result);
146
+ expect(result.emails).toEqual([]);
147
+ });
148
+ it("sets line to null when inputs array is empty", () => {
149
+ const configWithEmptyInputs = { ...mockSavedConfig, inputs: [] };
150
+ const result = processProductionFiles(metadataWithDecay, [configWithEmptyInputs], null);
151
+ if (typeof result === "string")
152
+ throw new Error(result);
153
+ expect(result.rows[0].line).toBeNull();
154
+ });
155
+ it("orders configs according to info.yaml job order", () => {
156
+ const configA = {
157
+ ...mockSavedConfig,
158
+ name: "DecayTreeTuple/TreeA",
159
+ inputs: ["/Event/Leptonic/Phys/BuToKJpsiee2Line/Particles"],
160
+ };
161
+ const configB = {
162
+ ...mockSavedConfig,
163
+ name: "DecayTreeTuple/TreeB",
164
+ inputs: ["/Event/Leptonic/Phys/BuToKJpsiee2Line/Particles"],
165
+ };
166
+ const infoYaml = {
167
+ defaults: {
168
+ application: "DaVinci/v45r7",
169
+ wg: "OpenData",
170
+ inform: [],
171
+ automatically_configure: true,
172
+ output: "DVNtuple.root",
173
+ },
174
+ job0: { input: { bk_query: "/q/DST" }, options: ["TreeB"] },
175
+ job1: { input: { bk_query: "/q2/DST" }, options: ["TreeA"] },
176
+ };
177
+ // Pass them in reverse order; info.yaml should sort them
178
+ const result = processProductionFiles(metadataWithDecay, [configA, configB], infoYaml);
179
+ if (typeof result === "string")
180
+ throw new Error(result);
181
+ expect(result.rows[0].dtt?.getName()).toBe("TreeB");
182
+ expect(result.rows[1].dtt?.getName()).toBe("TreeA");
183
+ });
184
+ });
185
+ // ---------------------------------------------------------------------------
186
+ // downloadText / downloadZip
187
+ // ---------------------------------------------------------------------------
188
+ // jsdom doesn't provide URL.createObjectURL; stub it globally
189
+ const mockCreateObjectURL = vi.fn(() => "blob:mock");
190
+ vi.stubGlobal("URL", {
191
+ ...URL,
192
+ createObjectURL: mockCreateObjectURL,
193
+ revokeObjectURL: vi.fn(),
194
+ });
195
+ // jsdom rejects MouseEvent with `view: window`; replace with a passthrough stub
196
+ vi.stubGlobal("MouseEvent", class MockMouseEvent {
197
+ });
198
+ describe("downloadText()", () => {
199
+ let mockLink;
200
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
201
+ let appendChildSpy;
202
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
203
+ let removeChildSpy;
204
+ beforeEach(() => {
205
+ mockCreateObjectURL.mockReturnValue("blob:mock");
206
+ mockLink = { href: "", download: "", dispatchEvent: vi.fn() };
207
+ vi.spyOn(document, "createElement").mockReturnValue(mockLink);
208
+ appendChildSpy = vi.spyOn(document.body, "appendChild").mockImplementation((n) => n);
209
+ removeChildSpy = vi.spyOn(document.body, "removeChild").mockImplementation((n) => n);
210
+ });
211
+ afterEach(() => {
212
+ vi.restoreAllMocks();
213
+ });
214
+ it("calls URL.createObjectURL with a Blob", () => {
215
+ const yamlFile = new YamlFile("test.yaml", "content: true");
216
+ downloadText(yamlFile);
217
+ expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob));
218
+ });
219
+ it("appends and removes a link element to trigger download", () => {
220
+ const yamlFile = new YamlFile("test.yaml", "content: true");
221
+ downloadText(yamlFile);
222
+ expect(appendChildSpy).toHaveBeenCalled();
223
+ expect(removeChildSpy).toHaveBeenCalled();
224
+ });
225
+ it("sanitizes the filename before setting the download attribute", () => {
226
+ const yamlFile = new YamlFile("../../evil.yaml", "x: 1");
227
+ downloadText(yamlFile);
228
+ expect(mockLink.download).toBe("evil.yaml");
229
+ });
230
+ });
231
+ describe("downloadZip()", () => {
232
+ let mockLink;
233
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
234
+ let appendChildSpy;
235
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
+ let removeChildSpy;
237
+ beforeEach(() => {
238
+ mockCreateObjectURL.mockReturnValue("blob:mock");
239
+ mockLink = { href: "", download: "", dispatchEvent: vi.fn() };
240
+ vi.spyOn(document, "createElement").mockReturnValue(mockLink);
241
+ appendChildSpy = vi.spyOn(document.body, "appendChild").mockImplementation((n) => n);
242
+ removeChildSpy = vi.spyOn(document.body, "removeChild").mockImplementation((n) => n);
243
+ });
244
+ afterEach(() => {
245
+ vi.restoreAllMocks();
246
+ });
247
+ it("creates a ZIP and triggers a download", async () => {
248
+ const files = [new YamlFile("a.yaml", "a: 1"), new YamlFile("b.yaml", "b: 2")];
249
+ await downloadZip(files, "my-archive.zip");
250
+ expect(mockCreateObjectURL).toHaveBeenCalledWith(expect.any(Blob));
251
+ expect(appendChildSpy).toHaveBeenCalled();
252
+ expect(removeChildSpy).toHaveBeenCalled();
253
+ });
254
+ it("uses default archive name when none provided", async () => {
255
+ const files = [new YamlFile("a.yaml", "a: 1")];
256
+ await downloadZip(files);
257
+ expect(mockLink.download).toBe("archive.zip");
258
+ });
259
+ });
260
+ // ---------------------------------------------------------------------------
261
+ // getControlKey
262
+ // ---------------------------------------------------------------------------
263
+ describe("getControlKey()", () => {
264
+ afterEach(() => {
265
+ vi.restoreAllMocks();
266
+ });
267
+ it("returns 'cmd' on Mac", () => {
268
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36");
269
+ expect(getControlKey()).toBe("cmd");
270
+ });
271
+ it("returns 'ctrl' on non-Mac", () => {
272
+ vi.spyOn(navigator, "userAgent", "get").mockReturnValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
273
+ expect(getControlKey()).toBe("ctrl");
274
+ });
275
+ });
276
+ // ---------------------------------------------------------------------------
277
+ // parseProductionFiles (async)
278
+ // ---------------------------------------------------------------------------
279
+ function makeFile(name, content) {
280
+ return new File([content], name, { type: "text/plain" });
281
+ }
282
+ const validDttYaml = `
283
+ name: DecayTreeTuple/MyTree
284
+ descriptorTemplate: "[\${head}B+ -> \${k}K+ \${mup}mu+ \${mum}mu-]CC"
285
+ inputs:
286
+ - /Event/Leptonic/Phys/BuToKJpsiee2Line/Particles
287
+ tools:
288
+ - TupleToolKinematic:
289
+ Verbose: false
290
+ branches:
291
+ head:
292
+ particle: "B+"
293
+ tools: []
294
+ k:
295
+ particle: "K+"
296
+ tools: []
297
+ mup:
298
+ particle: "mu+"
299
+ tools: []
300
+ mum:
301
+ particle: "mu-"
302
+ tools: []
303
+ groups: {}
304
+ `.trim();
305
+ const validInfoYaml = `
306
+ defaults:
307
+ application: DaVinci/v45r7
308
+ wg: OpenData
309
+ inform:
310
+ - user@cern.ch
311
+ automatically_configure: true
312
+ output: DVNtuple.root
313
+ `.trim();
314
+ describe("parseProductionFiles()", () => {
315
+ it("parses a valid DTT config file and returns rows", async () => {
316
+ const dttFile = makeFile("my_tree.yaml", validDttYaml);
317
+ const result = await parseProductionFiles([dttFile], metadataWithDecay);
318
+ expect(result).not.toBeTypeOf("string");
319
+ if (typeof result === "string")
320
+ return;
321
+ expect(result.rows).toHaveLength(1);
322
+ });
323
+ it("returns an error string for an invalid YAML file", async () => {
324
+ const badFile = makeFile("my_tree.yaml", ": bad: yaml: {{{{");
325
+ const result = await parseProductionFiles([badFile], metadataWithDecay);
326
+ expect(result).toBeTypeOf("string");
327
+ expect(result).toContain("is not a valid yaml file");
328
+ });
329
+ it("returns an error string when info.yaml is invalid YAML", async () => {
330
+ const infoFile = makeFile("info.yaml", ": bad: yaml: {{{{");
331
+ const result = await parseProductionFiles([infoFile], metadataWithDecay);
332
+ expect(result).toBeTypeOf("string");
333
+ expect(result).toContain("info.yaml");
334
+ });
335
+ it("parses with valid info.yaml and extracts emails", async () => {
336
+ const dttFile = makeFile("my_tree.yaml", validDttYaml);
337
+ const infoFile = makeFile("info.yaml", validInfoYaml);
338
+ const result = await parseProductionFiles([dttFile, infoFile], metadataWithDecay);
339
+ expect(result).not.toBeTypeOf("string");
340
+ if (typeof result === "string")
341
+ return;
342
+ expect(result.emails).toContain("user@cern.ch");
343
+ });
344
+ it("returns an error when typia validation fails for DTT config", async () => {
345
+ // Override typia mock to fail for this test
346
+ const typia = await import("typia");
347
+ vi.mocked(typia.default.validate).mockReturnValueOnce({ success: false, errors: [], data: null });
348
+ vi.mocked(typia.default.validate).mockReturnValueOnce({ success: false, errors: [], data: null });
349
+ const dttFile = makeFile("my_tree.yaml", "name: test");
350
+ const result = await parseProductionFiles([dttFile], metadataWithDecay);
351
+ expect(result).toBeTypeOf("string");
352
+ // Reset mock
353
+ vi.mocked(typia.default.validate).mockImplementation((v) => ({ success: true, data: v }));
354
+ });
355
+ it("returns an error string when info.yaml fails typia validation", async () => {
356
+ const typia = await import("typia");
357
+ // First call is for info.yaml validation
358
+ vi.mocked(typia.default.validate).mockReturnValueOnce({ success: false, errors: [], data: null });
359
+ const infoFile = makeFile("info.yaml", "defaults: {}");
360
+ const result = await parseProductionFiles([infoFile], metadataWithDecay);
361
+ expect(result).toBeTypeOf("string");
362
+ expect(result).toContain("info.yaml");
363
+ vi.mocked(typia.default.validate).mockImplementation((v) => ({ success: true, data: v }));
364
+ });
365
+ it("skips addBkPaths for rows where the DTT has no name", () => {
366
+ // Config with no name: dtt.config.name will be undefined → line 63 skipped
367
+ const noNameConfig = { ...mockSavedConfig, name: undefined };
368
+ const infoYaml = {
369
+ defaults: {
370
+ application: "DaVinci/v45r7",
371
+ wg: "OpenData",
372
+ inform: [],
373
+ automatically_configure: true,
374
+ output: "DVNtuple.root",
375
+ },
376
+ job0: { input: { bk_query: "/some/path/DST" }, options: ["MyTree"] },
377
+ };
378
+ const result = processProductionFiles(metadataWithDecay, [noNameConfig], infoYaml);
379
+ if (typeof result === "string")
380
+ throw new Error(result);
381
+ // Row should have no paths because addBkPathsToRows skipped the nameless DTT row
382
+ expect(result.rows[0].paths).toHaveLength(0);
383
+ });
384
+ it("sorts configs alphabetically when order is equal (both not in info.yaml)", () => {
385
+ const configB = { ...mockSavedConfig, name: "DecayTreeTuple/BTree" };
386
+ const configA = { ...mockSavedConfig, name: "DecayTreeTuple/ATree" };
387
+ // No info.yaml → both get MAX_SAFE_INTEGER order → localeCompare used
388
+ const result = processProductionFiles(metadataWithDecay, [configB, configA], null);
389
+ if (typeof result === "string")
390
+ throw new Error(result);
391
+ expect(result.rows[0].dtt?.getName()).toBe("ATree");
392
+ expect(result.rows[1].dtt?.getName()).toBe("BTree");
393
+ });
394
+ });
@@ -0,0 +1,13 @@
1
+ import { DecayData } from "../models/decayData";
2
+ import { MetadataContext } from "../providers/MetadataProvider";
3
+ export declare const LATEX_SELECTED_COLOR = "#0d6efd";
4
+ /**
5
+ * Converts a LoKi decay selector string to LaTeX representation
6
+ */
7
+ export declare const selectionDescriptorToLatex: (metadata: MetadataContext, decay: DecayData, selection: string[]) => string;
8
+ /**
9
+ * Converts a single particle token to LaTeX representation.
10
+ * Particle tokens may include a LoKi selection prefix ('^') and/or sub-decay parentheses.
11
+ * The `highlighted` param can be used instead of a '^' prefix when calling directly.
12
+ */
13
+ export declare const convertParticleToLatex: (metadata: MetadataContext, particle: string, highlighted?: boolean) => string;
@@ -0,0 +1,86 @@
1
+ export const LATEX_SELECTED_COLOR = "#0d6efd";
2
+ const LOKI_MARKERS = {
3
+ SELECTION_PREFIX: "^",
4
+ ARROW: "->",
5
+ };
6
+ const LATEX_COMMANDS = {
7
+ ARROW: "\\to",
8
+ TEXT: (content) => `\\text{${content}}`,
9
+ COLOR: (color, content) => `\\textcolor{${color}}{${content}}`,
10
+ };
11
+ /**
12
+ * Removes leading and trailing square brackets from LoKi selector string
13
+ */
14
+ const cleanLoKiSelector = (selector) => {
15
+ return selector.replace(/^\[/, "").replace(/^\^\[/, "^").replace(/\]CC$/, "");
16
+ };
17
+ /**
18
+ * Counts occurrences of a pattern in a string
19
+ */
20
+ const countMatches = (text, pattern) => {
21
+ return (text.match(pattern) || []).length;
22
+ };
23
+ /**
24
+ * Extracts and processes parentheses from particle names for sub-decay handling
25
+ */
26
+ const extractParentheses = (name) => {
27
+ const openCount = countMatches(name, /\(/g);
28
+ const closeCount = countMatches(name, /\)/g);
29
+ if (name.startsWith("(")) {
30
+ return {
31
+ cleanName: name.replace(/^\(/, ""),
32
+ openBrace: "(",
33
+ closeBrace: "",
34
+ };
35
+ }
36
+ if (name.endsWith(")") && closeCount > openCount) {
37
+ const extraClosingBraces = closeCount - openCount;
38
+ return {
39
+ cleanName: name.replace(/\)+$/, ""),
40
+ openBrace: "",
41
+ closeBrace: ")".repeat(extraClosingBraces),
42
+ };
43
+ }
44
+ return { cleanName: name, openBrace: "", closeBrace: "" };
45
+ };
46
+ /**
47
+ * Converts a LoKi decay selector string to LaTeX representation
48
+ */
49
+ export const selectionDescriptorToLatex = (metadata, decay, selection) => {
50
+ // Generate LoKi selector by marking selected particles with '^'
51
+ const decaySelector = decay.descriptors.template.replace(/\${(\w+)}/g, (_, variableName) => {
52
+ return selection.includes(variableName) ? LOKI_MARKERS.SELECTION_PREFIX : "";
53
+ });
54
+ const cleanedSelector = cleanLoKiSelector(decaySelector);
55
+ const particles = cleanedSelector.split(" ");
56
+ const latexTokens = particles.map((particle) => convertParticleToLatex(metadata, particle));
57
+ return latexTokens.join(" ");
58
+ };
59
+ /**
60
+ * Converts a single particle token to LaTeX representation.
61
+ * Particle tokens may include a LoKi selection prefix ('^') and/or sub-decay parentheses.
62
+ * The `highlighted` param can be used instead of a '^' prefix when calling directly.
63
+ */
64
+ export const convertParticleToLatex = (metadata, particle, highlighted = false) => {
65
+ // Handle arrow tokens
66
+ if (particle === LOKI_MARKERS.ARROW) {
67
+ return LATEX_COMMANDS.ARROW;
68
+ }
69
+ const hasPrefix = particle.startsWith(LOKI_MARKERS.SELECTION_PREFIX);
70
+ const isMarked = highlighted || hasPrefix;
71
+ const particleName = hasPrefix ? particle.slice(LOKI_MARKERS.SELECTION_PREFIX.length) : particle;
72
+ // Handle sub-decay parentheses
73
+ const { cleanName, openBrace, closeBrace } = extractParentheses(particleName);
74
+ // Validate particle exists in metadata
75
+ if (!metadata.particleProperties[cleanName]) {
76
+ console.error(`${cleanName} not found in particle property metadata!`);
77
+ return LATEX_COMMANDS.TEXT(cleanName);
78
+ }
79
+ // Get base LaTeX representation
80
+ let latex = metadata.particleProperties[cleanName].latex;
81
+ // Color highlighted particles
82
+ if (isMarked) {
83
+ latex = LATEX_COMMANDS.COLOR(LATEX_SELECTED_COLOR, latex);
84
+ }
85
+ return `${openBrace}${latex}${closeBrace}`;
86
+ };
@@ -40,3 +40,4 @@ export declare function downloadText(yamlFile: YamlFile, type?: string): void;
40
40
  * @param name - The desired archive filename
41
41
  */
42
42
  export declare function downloadZip(files: YamlFile[], name?: string): Promise<void>;
43
+ export declare function getControlKey(): "cmd" | "ctrl";
@@ -560,7 +560,7 @@ export function processProductionFiles(metadata, configs, infoYaml) {
560
560
  rows.push({
561
561
  id: rows.length,
562
562
  decay,
563
- lines: config.inputs.map((input) => parseInput(decay, input)),
563
+ line: config.inputs.length > 0 ? parseInput(decay, config.inputs[0]) : null,
564
564
  paths: [],
565
565
  pathOptions: [],
566
566
  dtt: Dtt.fromSavedConfig(config, metadata.tupleTools.tupleTools),
@@ -644,3 +644,6 @@ export async function downloadZip(files, name = "archive.zip") {
644
644
  const blob = await zip.generateAsync({ type: "blob" });
645
645
  downloadBlob(blob, safeZipName);
646
646
  }
647
+ export function getControlKey() {
648
+ return navigator.userAgent.includes("Mac") ? "cmd" : "ctrl";
649
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lhcb-ntuple-wizard-test",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "An application to access large-scale open data from LHCb",
5
5
  "url": "https://gitlab.cern.ch/lhcb-dpa/wp6-analysis-preservation-and-open-data/lhcb-ntuple-wizard-frontend/issues",
6
6
  "private": false,
@@ -17,22 +17,21 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@mathjax/src": "4.1.0",
20
- "better-react-mathjax": "2.3.0",
21
20
  "bootstrap": "5.3.8",
22
21
  "cytoscape": "3.33.1",
23
22
  "cytoscape-dagre": "^2.5.0",
24
- "dompurify": "^3.3.1",
25
23
  "email-validator": "2.0.4",
26
24
  "js-yaml": "4.1.1",
27
25
  "jszip": "3.10.1",
26
+ "katex": "^0.16.33",
28
27
  "lodash": "^4.17.23",
29
28
  "lodash.memoize": "4.1.2",
30
- "mathjax-full": "3.2.2",
31
29
  "pako": "2.1.0",
32
30
  "react-bootstrap": "2.10.10",
33
31
  "react-bootstrap-icons": "1.11.6",
34
32
  "react-cytoscapejs": "^2.0.0",
35
33
  "react-infinite-scroll-component": "^6.1.1",
34
+ "react-katex": "^3.1.0",
36
35
  "react-select": "5.10.2",
37
36
  "react-toastify": "^11.0.5",
38
37
  "typia": "^11.0.3"
@@ -43,11 +42,13 @@
43
42
  "react-router-dom": ">=7.12.0"
44
43
  },
45
44
  "overrides": {
46
- "autoprefixer": "10.4.5"
45
+ "autoprefixer": "10.4.5",
46
+ "@xmldom/xmldom": "0.9.9"
47
47
  },
48
48
  "scripts": {
49
49
  "start": "vite --open",
50
50
  "build": "tsc",
51
+ "test": "vitest run --coverage",
51
52
  "prepublishOnly": "npm run build",
52
53
  "prepare": "ts-patch install"
53
54
  },
@@ -72,6 +73,10 @@
72
73
  "@babel/preset-react": "7.27.1",
73
74
  "@eslint/js": "^9.17.0",
74
75
  "@kennethwkz/unplugin-typia": "^2.6.7",
76
+ "@testing-library/dom": "^10.4.1",
77
+ "@testing-library/jest-dom": "^6.9.1",
78
+ "@testing-library/react": "^16.3.2",
79
+ "@testing-library/user-event": "^14.6.1",
75
80
  "@types/cytoscape-dagre": "^2.3.4",
76
81
  "@types/jest": "^30.0.0",
77
82
  "@types/js-yaml": "^4.0.9",
@@ -81,20 +86,24 @@
81
86
  "@types/react": "^19.2.7",
82
87
  "@types/react-cytoscapejs": "^1.2.6",
83
88
  "@types/react-dom": "^19.2.3",
89
+ "@types/react-katex": "^3.0.4",
84
90
  "@types/react-router-dom": "^5.3.3",
85
91
  "@types/vis": "^4.21.27",
86
92
  "@typescript-eslint/eslint-plugin": "^8.56.0",
87
93
  "@typescript-eslint/parser": "^8.56.0",
88
94
  "@vitejs/plugin-react": "5.1.2",
89
- "eslint": "^9.39.3",
95
+ "@vitest/coverage-v8": "^4.1.2",
96
+ "eslint": "^9.39.4",
90
97
  "eslint-plugin-react": "^7.37.5",
91
98
  "globals": "^16.0.0",
99
+ "jsdom": "^29.0.1",
92
100
  "react": "^19.2.3",
93
101
  "react-dom": "^19.2.3",
94
102
  "react-router-dom": "^7.12.0",
95
103
  "ts-patch": "^3.3.0",
96
104
  "typescript": "~5.9.3",
97
105
  "vite": "7.3.0",
98
- "vite-tsconfig-paths": "^6.0.3"
106
+ "vite-tsconfig-paths": "^6.0.3",
107
+ "vitest": "^4.1.2"
99
108
  }
100
109
  }
@@ -1,8 +0,0 @@
1
- interface Props {
2
- strippingLines: {
3
- [key: string]: string[];
4
- };
5
- selected: boolean;
6
- }
7
- export declare function NumStrippingLinesBadge({ strippingLines, selected }: Props): import("react/jsx-runtime").JSX.Element;
8
- export {};
@@ -1,10 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Badge, OverlayTrigger, Popover } from "react-bootstrap";
3
- import { StrippingLineInfo } from "./StrippingLineInfo.js";
4
- export function NumStrippingLinesBadge({ strippingLines, selected }) {
5
- const numStrippingLines = Object.keys(strippingLines).length;
6
- return (_jsx(OverlayTrigger, { placement: "right", overlay: _jsxs(Popover, { children: [_jsx(Popover.Header, { children: "Stripping lines" }), _jsx(Popover.Body, { children: Object.keys(strippingLines).map((line) => {
7
- const [stream, name] = line.split("/");
8
- return (_jsx(StrippingLineInfo, { line: name, stream: stream, versions: strippingLines[line] }, line));
9
- }) })] }), children: _jsxs(Badge, { pill: true, bg: selected ? "primary" : "secondary", children: [numStrippingLines, " stripping line", numStrippingLines === 1 ? "" : "s"] }) }));
10
- }
@@ -1,7 +0,0 @@
1
- export interface StrippingBadgeProps {
2
- version: string;
3
- line?: string;
4
- stream?: string;
5
- showLink?: boolean;
6
- }
7
- export declare function StrippingLineBadge({ version, line, stream, showLink }: StrippingBadgeProps): import("react/jsx-runtime").JSX.Element;
@@ -1,5 +0,0 @@
1
- interface Props {
2
- onGroupSelected: (group: string) => void;
3
- }
4
- export declare function TupleToolGroup({ onGroupSelected }: Props): import("react/jsx-runtime").JSX.Element;
5
- export {};
@@ -1,22 +0,0 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
- import { Button, Card, ListGroup, OverlayTrigger, Popover } from "react-bootstrap";
3
- import { PencilSquare } from "react-bootstrap-icons";
4
- import { DecayLatex } from "./DecayLatex";
5
- import { useDtt } from "../providers/DttProvider";
6
- export function TupleToolGroup({ onGroupSelected }) {
7
- const { dtt, decay, selectedBranch } = useDtt();
8
- let groups = {};
9
- if (selectedBranch.length === 0) {
10
- groups = dtt.config.groups;
11
- }
12
- else if (selectedBranch.length === 1) {
13
- const filteredGroups = Object.entries(dtt.config.groups).filter(([key]) => key.includes(selectedBranch[0]));
14
- groups = Object.fromEntries(filteredGroups);
15
- }
16
- const groupKeys = Object.keys(groups);
17
- function ToolCount({ group }) {
18
- const tools = dtt.listTools(group);
19
- return (_jsx(OverlayTrigger, { overlay: _jsx(Popover, { children: _jsx(ListGroup, { children: tools.map((tool) => (_jsx(ListGroup.Item, { children: tool.toString() }, tool.toString()))) }) }), children: _jsxs("div", { children: [tools.length, " TupleTool", tools.length === 1 ? "" : "s"] }) }));
20
- }
21
- return (_jsxs(Card, { className: "mt-2", children: [_jsxs(Card.Header, { children: [groupKeys.length, " group", groupKeys.length === 1 ? "" : "s"] }), _jsx(ListGroup, { variant: "flush", children: groupKeys.map((selection) => (_jsxs(ListGroup.Item, { className: "d-flex justify-content-between align-items-start", children: [_jsx(DecayLatex, { decay: decay, selection: selection.split(",") }), _jsx(ToolCount, { group: selection.split(",") }), _jsx(Button, { variant: "outline-secondary", size: "sm", onClick: () => onGroupSelected(selection), children: _jsx(PencilSquare, {}) })] }, selection))) })] }));
22
- }
@@ -1,6 +0,0 @@
1
- interface Props {
2
- onGroupSelected: (group: string) => void;
3
- onSave: () => void;
4
- }
5
- export declare function TupleToolList({ onGroupSelected, onSave }: Props): import("react/jsx-runtime").JSX.Element | null;
6
- export {};