@morscherlab/mint-sdk 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (491) hide show
  1. package/README.md +326 -0
  2. package/dist/__stories__/experiment-helpers.d.ts +25 -0
  3. package/dist/__tests__/components/AppLayout.test.d.ts +1 -0
  4. package/dist/__tests__/components/AppSidebar.test.d.ts +1 -0
  5. package/dist/__tests__/components/AppTopBar.test.d.ts +1 -0
  6. package/dist/__tests__/components/BaseInput.test.d.ts +1 -0
  7. package/dist/__tests__/components/BasePill.test.d.ts +1 -0
  8. package/dist/__tests__/components/Calendar.test.d.ts +1 -0
  9. package/dist/__tests__/components/CollapsibleCard.test.d.ts +1 -0
  10. package/dist/__tests__/components/DataFrame.test.d.ts +1 -0
  11. package/dist/__tests__/components/DropdownButton.test.d.ts +1 -0
  12. package/dist/__tests__/composables/formBuilderRegistry.test.d.ts +1 -0
  13. package/dist/__tests__/composables/useAppExperiment.test.d.ts +1 -0
  14. package/dist/__tests__/composables/useAuth.test.d.ts +1 -0
  15. package/dist/__tests__/composables/useAutoGroup.test.d.ts +1 -0
  16. package/dist/__tests__/composables/useExperimentData.test.d.ts +13 -0
  17. package/dist/__tests__/composables/useExperimentSave.test.d.ts +1 -0
  18. package/dist/__tests__/composables/useForm.test.d.ts +1 -0
  19. package/dist/__tests__/composables/useFormBuilder.test.d.ts +1 -0
  20. package/dist/__tests__/composables/usePlatformContext.test.d.ts +1 -0
  21. package/dist/__tests__/composables/usePluginApi.test.d.ts +13 -0
  22. package/dist/__tests__/composables/usePluginConfig.test.d.ts +14 -0
  23. package/dist/__tests__/utils/color.test.d.ts +1 -0
  24. package/dist/auth-BYmxZdJl.js +297 -0
  25. package/dist/auth-BYmxZdJl.js.map +1 -0
  26. package/dist/components/AlertBox.vue.d.ts +34 -0
  27. package/dist/components/AppAvatarMenu.vue.d.ts +58 -0
  28. package/dist/components/AppContainer.vue.d.ts +28 -0
  29. package/dist/components/AppLayout.vue.d.ts +31 -0
  30. package/dist/components/AppPageSelector.vue.d.ts +43 -0
  31. package/dist/components/AppPillNav.vue.d.ts +11 -0
  32. package/dist/components/AppPluginSwitcher.vue.d.ts +38 -0
  33. package/dist/components/AppSidebar.vue.d.ts +47 -0
  34. package/dist/components/AppTopBar.vue.d.ts +111 -0
  35. package/dist/components/AuditTrail.vue.d.ts +38 -0
  36. package/dist/components/AutoGroupModal.vue.d.ts +124 -0
  37. package/dist/components/Avatar.vue.d.ts +14 -0
  38. package/dist/components/BaseButton.vue.d.ts +37 -0
  39. package/dist/components/BaseCheckbox.vue.d.ts +17 -0
  40. package/dist/components/BaseInput.vue.d.ts +34 -0
  41. package/dist/components/BaseModal.vue.d.ts +46 -0
  42. package/dist/components/BasePill.vue.d.ts +57 -0
  43. package/dist/components/BaseRadioGroup.vue.d.ts +21 -0
  44. package/dist/components/BaseSelect.vue.d.ts +20 -0
  45. package/dist/components/BaseSlider.vue.d.ts +22 -0
  46. package/dist/components/BaseTabs.vue.d.ts +14 -0
  47. package/dist/components/BaseTextarea.vue.d.ts +30 -0
  48. package/dist/components/BaseToggle.vue.d.ts +19 -0
  49. package/dist/components/BatchProgressList.vue.d.ts +43 -0
  50. package/dist/components/Breadcrumb.vue.d.ts +33 -0
  51. package/dist/components/Calendar.vue.d.ts +107 -0
  52. package/dist/components/ChartContainer.vue.d.ts +31 -0
  53. package/dist/components/ChemicalFormula.vue.d.ts +8 -0
  54. package/dist/components/CollapsibleCard.vue.d.ts +41 -0
  55. package/dist/components/ColorSlider.vue.d.ts +34 -0
  56. package/dist/components/ConcentrationInput.vue.d.ts +25 -0
  57. package/dist/components/ConfirmDialog.vue.d.ts +42 -0
  58. package/dist/components/DataFrame.vue.d.ts +107 -0
  59. package/dist/components/DatePicker.vue.d.ts +25 -0
  60. package/dist/components/DateTimePicker.vue.d.ts +30 -0
  61. package/dist/components/Divider.vue.d.ts +14 -0
  62. package/dist/components/DoseCalculator.vue.d.ts +19 -0
  63. package/dist/components/DropdownButton.vue.d.ts +47 -0
  64. package/dist/components/EmptyState.vue.d.ts +36 -0
  65. package/dist/components/ExperimentCodeBadge.vue.d.ts +14 -0
  66. package/dist/components/ExperimentDataViewer.vue.d.ts +29 -0
  67. package/dist/components/ExperimentPopover.vue.d.ts +32 -0
  68. package/dist/components/ExperimentSelectorModal.vue.d.ts +28 -0
  69. package/dist/components/ExperimentTimeline.vue.d.ts +44 -0
  70. package/dist/components/FileUploader.vue.d.ts +40 -0
  71. package/dist/components/FitPanel.vue.d.ts +46 -0
  72. package/dist/components/FormActions.vue.d.ts +33 -0
  73. package/dist/components/FormBuilder.vue.d.ts +287 -0
  74. package/dist/components/FormField.vue.d.ts +28 -0
  75. package/dist/components/FormFieldRenderer.vue.d.ts +31 -0
  76. package/dist/components/FormSection.vue.d.ts +43 -0
  77. package/dist/components/FormulaInput.vue.d.ts +25 -0
  78. package/dist/components/GroupAssigner.vue.d.ts +25 -0
  79. package/dist/components/GroupingModal.vue.d.ts +12 -0
  80. package/dist/components/IconButton.vue.d.ts +34 -0
  81. package/dist/components/LoadingSpinner.vue.d.ts +12 -0
  82. package/dist/components/MoleculeInput.vue.d.ts +27 -0
  83. package/dist/components/MultiSelect.vue.d.ts +19 -0
  84. package/dist/components/NumberInput.vue.d.ts +22 -0
  85. package/dist/components/PlateMapEditor.vue.d.ts +50 -0
  86. package/dist/components/ProgressBar.vue.d.ts +23 -0
  87. package/dist/components/ProtocolStepEditor.vue.d.ts +24 -0
  88. package/dist/components/RackEditor.vue.d.ts +40 -0
  89. package/dist/components/ReagentEditor.vue.d.ts +30 -0
  90. package/dist/components/ReagentList.vue.d.ts +32 -0
  91. package/dist/components/ResourceCard.vue.d.ts +50 -0
  92. package/dist/components/SampleHierarchyTree.vue.d.ts +26 -0
  93. package/dist/components/SampleLegend.vue.d.ts +32 -0
  94. package/dist/components/SampleSelector.vue.d.ts +29 -0
  95. package/dist/components/ScheduleCalendar.vue.d.ts +110 -0
  96. package/dist/components/ScientificNumber.vue.d.ts +14 -0
  97. package/dist/components/SegmentedControl.vue.d.ts +20 -0
  98. package/dist/components/SequenceInput.vue.d.ts +54 -0
  99. package/dist/components/SettingsButton.vue.d.ts +30 -0
  100. package/dist/components/SettingsModal.vue.d.ts +36 -0
  101. package/dist/components/Skeleton.vue.d.ts +11 -0
  102. package/dist/components/StatusIndicator.vue.d.ts +13 -0
  103. package/dist/components/StepWizard.vue.d.ts +65 -0
  104. package/dist/components/TagsInput.vue.d.ts +39 -0
  105. package/dist/components/ThemeToggle.vue.d.ts +7 -0
  106. package/dist/components/TimePicker.vue.d.ts +29 -0
  107. package/dist/components/TimeRangeInput.vue.d.ts +27 -0
  108. package/dist/components/ToastNotification.vue.d.ts +2 -0
  109. package/dist/components/Tooltip.vue.d.ts +35 -0
  110. package/dist/components/UnitInput.vue.d.ts +39 -0
  111. package/dist/components/WellEditPopup.vue.d.ts +25 -0
  112. package/dist/components/WellPlate.vue.d.ts +73 -0
  113. package/dist/components/index.d.ts +87 -0
  114. package/dist/components/index.js +3 -0
  115. package/dist/components-CKf-UpGi.js +15089 -0
  116. package/dist/components-CKf-UpGi.js.map +1 -0
  117. package/dist/composables/experiment-utils.d.ts +8 -0
  118. package/dist/composables/formBuilderRegistry.d.ts +13 -0
  119. package/dist/composables/index.d.ts +28 -0
  120. package/dist/composables/index.js +3 -0
  121. package/dist/composables/useApi.d.ts +20 -0
  122. package/dist/composables/useAppExperiment.d.ts +37 -0
  123. package/dist/composables/useAsync.d.ts +128 -0
  124. package/dist/composables/useAuth.d.ts +47 -0
  125. package/dist/composables/useAutoGroup.d.ts +106 -0
  126. package/dist/composables/useChemicalFormula.d.ts +21 -0
  127. package/dist/composables/useConcentrationUnits.d.ts +29 -0
  128. package/dist/composables/useDoseCalculator.d.ts +58 -0
  129. package/dist/composables/useExperimentData.d.ts +18 -0
  130. package/dist/composables/useExperimentSave.d.ts +36 -0
  131. package/dist/composables/useExperimentSelector.d.ts +30 -0
  132. package/dist/composables/useForm.d.ts +92 -0
  133. package/dist/composables/useFormBuilder.d.ts +24 -0
  134. package/dist/composables/usePasskey.d.ts +10 -0
  135. package/dist/composables/usePlatformContext.d.ts +131 -0
  136. package/dist/composables/usePluginApi.d.ts +29 -0
  137. package/dist/composables/usePluginConfig.d.ts +13 -0
  138. package/dist/composables/useProtocolTemplates.d.ts +44 -0
  139. package/dist/composables/useRackEditor.d.ts +31 -0
  140. package/dist/composables/useReagentSeries.d.ts +23 -0
  141. package/dist/composables/useScheduleDrag.d.ts +78 -0
  142. package/dist/composables/useSequenceUtils.d.ts +14 -0
  143. package/dist/composables/useTheme.d.ts +8 -0
  144. package/dist/composables/useTimeUtils.d.ts +29 -0
  145. package/dist/composables/useToast.d.ts +22 -0
  146. package/dist/composables/useWellPlateEditor.d.ts +33 -0
  147. package/dist/composables-D0QfFzq1.js +805 -0
  148. package/dist/composables-D0QfFzq1.js.map +1 -0
  149. package/dist/histoire.setup.d.ts +1 -0
  150. package/dist/index.d.ts +6 -0
  151. package/dist/index.js +7 -0
  152. package/dist/install.d.ts +16 -0
  153. package/dist/install.js +23 -0
  154. package/dist/install.js.map +1 -0
  155. package/dist/stores/auth.d.ts +146 -0
  156. package/dist/stores/index.d.ts +2 -0
  157. package/dist/stores/index.js +2 -0
  158. package/dist/stores/settings.d.ts +75 -0
  159. package/dist/styles.css +29728 -0
  160. package/dist/tailwind.preset.d.ts +58 -0
  161. package/dist/tailwind.preset.js +66 -0
  162. package/dist/tailwind.preset.js.map +1 -0
  163. package/dist/types/auth.d.ts +42 -0
  164. package/dist/types/auto-group.d.ts +34 -0
  165. package/dist/types/components.d.ts +528 -0
  166. package/dist/types/form-builder.d.ts +167 -0
  167. package/dist/types/index.d.ts +5 -0
  168. package/dist/types/index.js +0 -0
  169. package/dist/types/platform.d.ts +75 -0
  170. package/dist/useScheduleDrag-DAJueTbK.js +7181 -0
  171. package/dist/useScheduleDrag-DAJueTbK.js.map +1 -0
  172. package/dist/utils/color.d.ts +24 -0
  173. package/package.json +114 -0
  174. package/src/__stories__/experiment-helpers.ts +83 -0
  175. package/src/__tests__/components/AppLayout.test.ts +163 -0
  176. package/src/__tests__/components/AppSidebar.test.ts +292 -0
  177. package/src/__tests__/components/AppTopBar.test.ts +683 -0
  178. package/src/__tests__/components/BaseInput.test.ts +99 -0
  179. package/src/__tests__/components/BasePill.test.ts +291 -0
  180. package/src/__tests__/components/Calendar.test.ts +566 -0
  181. package/src/__tests__/components/CollapsibleCard.test.ts +524 -0
  182. package/src/__tests__/components/DataFrame.test.ts +767 -0
  183. package/src/__tests__/components/DropdownButton.test.ts +471 -0
  184. package/src/__tests__/composables/formBuilderRegistry.test.ts +187 -0
  185. package/src/__tests__/composables/useAppExperiment.test.ts +560 -0
  186. package/src/__tests__/composables/useAuth.test.ts +188 -0
  187. package/src/__tests__/composables/useAutoGroup.test.ts +860 -0
  188. package/src/__tests__/composables/useExperimentData.test.ts +127 -0
  189. package/src/__tests__/composables/useExperimentSave.test.ts +347 -0
  190. package/src/__tests__/composables/useForm.test.ts +205 -0
  191. package/src/__tests__/composables/useFormBuilder.test.ts +917 -0
  192. package/src/__tests__/composables/usePlatformContext.test.ts +116 -0
  193. package/src/__tests__/composables/usePluginApi.test.ts +81 -0
  194. package/src/__tests__/composables/usePluginConfig.test.ts +176 -0
  195. package/src/__tests__/utils/color.test.ts +96 -0
  196. package/src/components/AlertBox.story.vue +204 -0
  197. package/src/components/AlertBox.vue +88 -0
  198. package/src/components/AppAvatarMenu.story.vue +155 -0
  199. package/src/components/AppAvatarMenu.vue +184 -0
  200. package/src/components/AppContainer.story.vue +104 -0
  201. package/src/components/AppContainer.vue +34 -0
  202. package/src/components/AppLayout.story.vue +292 -0
  203. package/src/components/AppLayout.vue +75 -0
  204. package/src/components/AppPageSelector.vue +159 -0
  205. package/src/components/AppPillNav.vue +66 -0
  206. package/src/components/AppPluginSwitcher.vue +241 -0
  207. package/src/components/AppSidebar.story.vue +309 -0
  208. package/src/components/AppSidebar.vue +119 -0
  209. package/src/components/AppTopBar.story.vue +304 -0
  210. package/src/components/AppTopBar.vue +661 -0
  211. package/src/components/AuditTrail.story.vue +163 -0
  212. package/src/components/AuditTrail.vue +151 -0
  213. package/src/components/AutoGroupModal.story.vue +273 -0
  214. package/src/components/AutoGroupModal.vue +566 -0
  215. package/src/components/Avatar.story.vue +115 -0
  216. package/src/components/Avatar.vue +79 -0
  217. package/src/components/BaseButton.story.vue +96 -0
  218. package/src/components/BaseButton.vue +73 -0
  219. package/src/components/BaseCheckbox.story.vue +73 -0
  220. package/src/components/BaseCheckbox.vue +69 -0
  221. package/src/components/BaseInput.story.vue +98 -0
  222. package/src/components/BaseInput.vue +74 -0
  223. package/src/components/BaseModal.story.vue +237 -0
  224. package/src/components/BaseModal.vue +182 -0
  225. package/src/components/BasePill.story.vue +142 -0
  226. package/src/components/BasePill.vue +89 -0
  227. package/src/components/BaseRadioGroup.story.vue +145 -0
  228. package/src/components/BaseRadioGroup.vue +124 -0
  229. package/src/components/BaseSelect.story.vue +120 -0
  230. package/src/components/BaseSelect.vue +71 -0
  231. package/src/components/BaseSlider.story.vue +122 -0
  232. package/src/components/BaseSlider.vue +126 -0
  233. package/src/components/BaseTabs.story.vue +127 -0
  234. package/src/components/BaseTabs.vue +59 -0
  235. package/src/components/BaseTextarea.story.vue +91 -0
  236. package/src/components/BaseTextarea.vue +62 -0
  237. package/src/components/BaseToggle.story.vue +81 -0
  238. package/src/components/BaseToggle.vue +76 -0
  239. package/src/components/BatchProgressList.story.vue +92 -0
  240. package/src/components/BatchProgressList.vue +184 -0
  241. package/src/components/Breadcrumb.story.vue +106 -0
  242. package/src/components/Breadcrumb.vue +75 -0
  243. package/src/components/Calendar.story.vue +106 -0
  244. package/src/components/Calendar.vue +363 -0
  245. package/src/components/ChartContainer.story.vue +113 -0
  246. package/src/components/ChartContainer.vue +64 -0
  247. package/src/components/ChemicalFormula.story.vue +102 -0
  248. package/src/components/ChemicalFormula.vue +39 -0
  249. package/src/components/CollapsibleCard.story.vue +135 -0
  250. package/src/components/CollapsibleCard.vue +167 -0
  251. package/src/components/ColorSlider.story.vue +120 -0
  252. package/src/components/ColorSlider.vue +164 -0
  253. package/src/components/ConcentrationInput.story.vue +77 -0
  254. package/src/components/ConcentrationInput.vue +185 -0
  255. package/src/components/ConfirmDialog.story.vue +248 -0
  256. package/src/components/ConfirmDialog.vue +93 -0
  257. package/src/components/DataFrame.story.vue +148 -0
  258. package/src/components/DataFrame.vue +419 -0
  259. package/src/components/DatePicker.story.vue +119 -0
  260. package/src/components/DatePicker.vue +330 -0
  261. package/src/components/DateTimePicker.story.vue +112 -0
  262. package/src/components/DateTimePicker.vue +392 -0
  263. package/src/components/Divider.story.vue +80 -0
  264. package/src/components/Divider.vue +49 -0
  265. package/src/components/DoseCalculator.story.vue +68 -0
  266. package/src/components/DoseCalculator.vue +476 -0
  267. package/src/components/DropdownButton.story.vue +102 -0
  268. package/src/components/DropdownButton.vue +181 -0
  269. package/src/components/EmptyState.story.vue +135 -0
  270. package/src/components/EmptyState.vue +69 -0
  271. package/src/components/ExperimentCodeBadge.story.vue +77 -0
  272. package/src/components/ExperimentCodeBadge.vue +64 -0
  273. package/src/components/ExperimentDataViewer.story.vue +174 -0
  274. package/src/components/ExperimentDataViewer.vue +288 -0
  275. package/src/components/ExperimentPopover.story.vue +384 -0
  276. package/src/components/ExperimentPopover.vue +241 -0
  277. package/src/components/ExperimentSelectorModal.story.vue +391 -0
  278. package/src/components/ExperimentSelectorModal.vue +387 -0
  279. package/src/components/ExperimentTimeline.story.vue +161 -0
  280. package/src/components/ExperimentTimeline.vue +382 -0
  281. package/src/components/FileUploader.story.vue +107 -0
  282. package/src/components/FileUploader.vue +386 -0
  283. package/src/components/FitPanel.story.vue +125 -0
  284. package/src/components/FitPanel.vue +120 -0
  285. package/src/components/FormActions.vue +92 -0
  286. package/src/components/FormBuilder.vue +214 -0
  287. package/src/components/FormField.story.vue +132 -0
  288. package/src/components/FormField.vue +59 -0
  289. package/src/components/FormFieldRenderer.vue +58 -0
  290. package/src/components/FormSection.vue +90 -0
  291. package/src/components/FormulaInput.story.vue +96 -0
  292. package/src/components/FormulaInput.vue +125 -0
  293. package/src/components/GroupAssigner.story.vue +83 -0
  294. package/src/components/GroupAssigner.vue +284 -0
  295. package/src/components/GroupingModal.story.vue +52 -0
  296. package/src/components/GroupingModal.vue +422 -0
  297. package/src/components/IconButton.story.vue +135 -0
  298. package/src/components/IconButton.vue +73 -0
  299. package/src/components/LoadingSpinner.story.vue +70 -0
  300. package/src/components/LoadingSpinner.vue +50 -0
  301. package/src/components/MoleculeInput.story.vue +66 -0
  302. package/src/components/MoleculeInput.vue +426 -0
  303. package/src/components/MultiSelect.story.vue +132 -0
  304. package/src/components/MultiSelect.vue +118 -0
  305. package/src/components/NumberInput.story.vue +122 -0
  306. package/src/components/NumberInput.vue +160 -0
  307. package/src/components/PlateMapEditor.story.vue +92 -0
  308. package/src/components/PlateMapEditor.vue +513 -0
  309. package/src/components/ProgressBar.story.vue +148 -0
  310. package/src/components/ProgressBar.vue +114 -0
  311. package/src/components/ProtocolStepEditor.story.vue +69 -0
  312. package/src/components/ProtocolStepEditor.vue +522 -0
  313. package/src/components/RackEditor.story.vue +100 -0
  314. package/src/components/RackEditor.vue +371 -0
  315. package/src/components/ReagentEditor.story.vue +153 -0
  316. package/src/components/ReagentEditor.vue +418 -0
  317. package/src/components/ReagentList.story.vue +137 -0
  318. package/src/components/ReagentList.vue +463 -0
  319. package/src/components/ResourceCard.story.vue +150 -0
  320. package/src/components/ResourceCard.vue +161 -0
  321. package/src/components/SampleHierarchyTree.story.vue +161 -0
  322. package/src/components/SampleHierarchyTree.vue +256 -0
  323. package/src/components/SampleLegend.story.vue +91 -0
  324. package/src/components/SampleLegend.vue +119 -0
  325. package/src/components/SampleSelector.story.vue +111 -0
  326. package/src/components/SampleSelector.vue +1033 -0
  327. package/src/components/ScheduleCalendar.story.vue +195 -0
  328. package/src/components/ScheduleCalendar.vue +569 -0
  329. package/src/components/ScientificNumber.story.vue +127 -0
  330. package/src/components/ScientificNumber.vue +197 -0
  331. package/src/components/SegmentedControl.story.vue +132 -0
  332. package/src/components/SegmentedControl.vue +79 -0
  333. package/src/components/SequenceInput.story.vue +119 -0
  334. package/src/components/SequenceInput.vue +209 -0
  335. package/src/components/SettingsButton.story.vue +58 -0
  336. package/src/components/SettingsButton.vue +76 -0
  337. package/src/components/SettingsModal.story.vue +145 -0
  338. package/src/components/SettingsModal.vue +146 -0
  339. package/src/components/Skeleton.story.vue +141 -0
  340. package/src/components/Skeleton.vue +74 -0
  341. package/src/components/StatusIndicator.story.vue +99 -0
  342. package/src/components/StatusIndicator.vue +40 -0
  343. package/src/components/StepWizard.story.vue +155 -0
  344. package/src/components/StepWizard.vue +223 -0
  345. package/src/components/TagsInput.story.vue +155 -0
  346. package/src/components/TagsInput.vue +265 -0
  347. package/src/components/ThemeToggle.story.vue +36 -0
  348. package/src/components/ThemeToggle.vue +54 -0
  349. package/src/components/TimePicker.story.vue +96 -0
  350. package/src/components/TimePicker.vue +273 -0
  351. package/src/components/TimeRangeInput.story.vue +104 -0
  352. package/src/components/TimeRangeInput.vue +122 -0
  353. package/src/components/ToastNotification.story.vue +157 -0
  354. package/src/components/ToastNotification.vue +62 -0
  355. package/src/components/Tooltip.story.vue +138 -0
  356. package/src/components/Tooltip.vue +119 -0
  357. package/src/components/UnitInput.story.vue +194 -0
  358. package/src/components/UnitInput.vue +213 -0
  359. package/src/components/WellEditPopup.vue +234 -0
  360. package/src/components/WellPlate.story.vue +282 -0
  361. package/src/components/WellPlate.vue +830 -0
  362. package/src/components/index.ts +118 -0
  363. package/src/composables/experiment-utils.ts +57 -0
  364. package/src/composables/formBuilderRegistry.ts +79 -0
  365. package/src/composables/index.ts +140 -0
  366. package/src/composables/useApi.ts +167 -0
  367. package/src/composables/useAppExperiment.ts +159 -0
  368. package/src/composables/useAsync.ts +323 -0
  369. package/src/composables/useAuth.ts +445 -0
  370. package/src/composables/useAutoGroup.ts +641 -0
  371. package/src/composables/useChemicalFormula.ts +275 -0
  372. package/src/composables/useConcentrationUnits.ts +246 -0
  373. package/src/composables/useDoseCalculator.ts +370 -0
  374. package/src/composables/useExperimentData.ts +86 -0
  375. package/src/composables/useExperimentSave.ts +192 -0
  376. package/src/composables/useExperimentSelector.ts +292 -0
  377. package/src/composables/useForm.ts +416 -0
  378. package/src/composables/useFormBuilder.ts +383 -0
  379. package/src/composables/usePasskey.ts +216 -0
  380. package/src/composables/usePlatformContext.ts +299 -0
  381. package/src/composables/usePluginApi.ts +39 -0
  382. package/src/composables/usePluginConfig.ts +93 -0
  383. package/src/composables/useProtocolTemplates.ts +518 -0
  384. package/src/composables/useRackEditor.ts +222 -0
  385. package/src/composables/useReagentSeries.ts +91 -0
  386. package/src/composables/useScheduleDrag.ts +245 -0
  387. package/src/composables/useSequenceUtils.ts +105 -0
  388. package/src/composables/useTheme.ts +58 -0
  389. package/src/composables/useTimeUtils.ts +131 -0
  390. package/src/composables/useToast.ts +40 -0
  391. package/src/composables/useWellPlateEditor.ts +421 -0
  392. package/src/histoire.setup.ts +17 -0
  393. package/src/index.ts +367 -0
  394. package/src/install.ts +32 -0
  395. package/src/stores/auth.ts +152 -0
  396. package/src/stores/index.ts +2 -0
  397. package/src/stores/settings.ts +218 -0
  398. package/src/styles/components/alert-box.css +150 -0
  399. package/src/styles/components/app-avatar-menu.css +155 -0
  400. package/src/styles/components/app-container.css +33 -0
  401. package/src/styles/components/app-layout.css +98 -0
  402. package/src/styles/components/app-page-selector.css +191 -0
  403. package/src/styles/components/app-pill-nav.css +57 -0
  404. package/src/styles/components/app-plugin-switcher.css +209 -0
  405. package/src/styles/components/app-sidebar.css +145 -0
  406. package/src/styles/components/app-top-bar.css +492 -0
  407. package/src/styles/components/audit-trail.css +143 -0
  408. package/src/styles/components/auto-group-modal.css +644 -0
  409. package/src/styles/components/avatar.css +73 -0
  410. package/src/styles/components/batch-progress-list.css +196 -0
  411. package/src/styles/components/breadcrumb.css +64 -0
  412. package/src/styles/components/button.css +188 -0
  413. package/src/styles/components/calendar.css +192 -0
  414. package/src/styles/components/chart-container.css +69 -0
  415. package/src/styles/components/checkbox.css +123 -0
  416. package/src/styles/components/chemical-formula.css +46 -0
  417. package/src/styles/components/collapsible-card.css +253 -0
  418. package/src/styles/components/color-slider.css +110 -0
  419. package/src/styles/components/concentration-input.css +156 -0
  420. package/src/styles/components/confirm-dialog.css +183 -0
  421. package/src/styles/components/dataframe.css +382 -0
  422. package/src/styles/components/date-picker.css +243 -0
  423. package/src/styles/components/datetime-picker.css +229 -0
  424. package/src/styles/components/divider.css +63 -0
  425. package/src/styles/components/dose-calculator.css +301 -0
  426. package/src/styles/components/dropdown-button.css +280 -0
  427. package/src/styles/components/empty-state.css +151 -0
  428. package/src/styles/components/experiment-code-badge.css +33 -0
  429. package/src/styles/components/experiment-data-viewer.css +138 -0
  430. package/src/styles/components/experiment-popover.css +562 -0
  431. package/src/styles/components/experiment-selector-modal.css +285 -0
  432. package/src/styles/components/experiment-timeline.css +529 -0
  433. package/src/styles/components/file-uploader.css +310 -0
  434. package/src/styles/components/fit-panel.css +67 -0
  435. package/src/styles/components/form-builder.css +69 -0
  436. package/src/styles/components/form-field.css +48 -0
  437. package/src/styles/components/formula-input.css +103 -0
  438. package/src/styles/components/group-assigner.css +200 -0
  439. package/src/styles/components/grouping-modal.css +323 -0
  440. package/src/styles/components/icon-button.css +192 -0
  441. package/src/styles/components/input.css +66 -0
  442. package/src/styles/components/loading-spinner.css +67 -0
  443. package/src/styles/components/modal.css +350 -0
  444. package/src/styles/components/molecule-input.css +186 -0
  445. package/src/styles/components/multi-select.css +131 -0
  446. package/src/styles/components/number-input.css +199 -0
  447. package/src/styles/components/pill.css +188 -0
  448. package/src/styles/components/plate-map-editor.css +464 -0
  449. package/src/styles/components/progress-bar.css +133 -0
  450. package/src/styles/components/protocol-step-editor.css +449 -0
  451. package/src/styles/components/rack-editor.css +265 -0
  452. package/src/styles/components/radio-group.css +240 -0
  453. package/src/styles/components/reagent-editor.css +510 -0
  454. package/src/styles/components/reagent-list.css +407 -0
  455. package/src/styles/components/resource-card.css +360 -0
  456. package/src/styles/components/sample-hierarchy-tree.css +314 -0
  457. package/src/styles/components/sample-legend.css +201 -0
  458. package/src/styles/components/sample-selector.css +751 -0
  459. package/src/styles/components/schedule-calendar.css +478 -0
  460. package/src/styles/components/scientific-number.css +63 -0
  461. package/src/styles/components/segmented-control.css +197 -0
  462. package/src/styles/components/select.css +77 -0
  463. package/src/styles/components/sequence-input.css +184 -0
  464. package/src/styles/components/settings-button.css +94 -0
  465. package/src/styles/components/settings-modal.css +95 -0
  466. package/src/styles/components/skeleton.css +49 -0
  467. package/src/styles/components/slider.css +74 -0
  468. package/src/styles/components/status-indicator.css +66 -0
  469. package/src/styles/components/step-wizard.css +192 -0
  470. package/src/styles/components/tabs.css +95 -0
  471. package/src/styles/components/tags-input.css +195 -0
  472. package/src/styles/components/textarea.css +82 -0
  473. package/src/styles/components/theme-toggle.css +69 -0
  474. package/src/styles/components/time-picker.css +171 -0
  475. package/src/styles/components/time-range-input.css +42 -0
  476. package/src/styles/components/toast.css +91 -0
  477. package/src/styles/components/toggle.css +146 -0
  478. package/src/styles/components/tooltip.css +91 -0
  479. package/src/styles/components/unit-input.css +123 -0
  480. package/src/styles/components/well-edit-popup.css +252 -0
  481. package/src/styles/components/well-plate.css +307 -0
  482. package/src/styles/index.css +87 -0
  483. package/src/styles/variables.css +1117 -0
  484. package/src/tailwind.preset.ts +61 -0
  485. package/src/types/auth.ts +55 -0
  486. package/src/types/auto-group.ts +40 -0
  487. package/src/types/components.ts +710 -0
  488. package/src/types/form-builder.ts +197 -0
  489. package/src/types/index.ts +207 -0
  490. package/src/types/platform.ts +116 -0
  491. package/src/utils/color.ts +96 -0
@@ -0,0 +1,860 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ analyzeDelimiter,
4
+ detectOutliers,
5
+ classifyOutlierAction,
6
+ extractColumns,
7
+ parseCSV,
8
+ parseCSVLine,
9
+ computeGroups,
10
+ computeGroupsFromCsv,
11
+ extractSamplesFromDesignData,
12
+ DEFAULT_COLORS,
13
+ useAutoGroup,
14
+ } from '../../composables/useAutoGroup'
15
+ import type { OutlierAction } from '../../types/auto-group'
16
+
17
+ describe('analyzeDelimiter', () => {
18
+ it('should detect underscore delimiter', () => {
19
+ const lines = ['Ctrl_WT_1', 'Ctrl_WT_2', 'Treat_KO_1', 'Treat_KO_2']
20
+ const result = analyzeDelimiter(lines)
21
+ expect(result.delimiter).toBe('_')
22
+ expect(result.dominantFieldCount).toBe(3)
23
+ expect(result.consistency).toBe(1)
24
+ })
25
+
26
+ it('should detect hyphen delimiter', () => {
27
+ const lines = ['Ctrl-WT-1', 'Ctrl-WT-2', 'Treat-KO-1']
28
+ const result = analyzeDelimiter(lines)
29
+ expect(result.delimiter).toBe('-')
30
+ expect(result.dominantFieldCount).toBe(3)
31
+ })
32
+
33
+ it('should detect dot delimiter', () => {
34
+ const lines = ['Ctrl.WT.1', 'Ctrl.WT.2', 'Treat.KO.1']
35
+ const result = analyzeDelimiter(lines)
36
+ expect(result.delimiter).toBe('.')
37
+ expect(result.dominantFieldCount).toBe(3)
38
+ })
39
+
40
+ it('should pick most consistent delimiter with mixed usage', () => {
41
+ // Underscores used consistently (3 fields), hyphens appear but less consistently
42
+ const lines = ['Ctrl_WT_1', 'Ctrl_WT_2', 'Treat_KO-1', 'Treat_KO_2']
43
+ const result = analyzeDelimiter(lines)
44
+ expect(result.delimiter).toBe('_')
45
+ })
46
+
47
+ it('should prefer underscore when tied', () => {
48
+ // Both _ and - produce identical consistency
49
+ const lines = ['A_B-C']
50
+ const result = analyzeDelimiter(lines)
51
+ expect(result.delimiter).toBe('_')
52
+ })
53
+
54
+ it('should handle single-segment samples (no delimiter)', () => {
55
+ const lines = ['SampleA', 'SampleB', 'SampleC']
56
+ const result = analyzeDelimiter(lines)
57
+ expect(result.dominantFieldCount).toBe(1)
58
+ expect(result.minFieldCount).toBe(1)
59
+ })
60
+
61
+ it('should handle empty input', () => {
62
+ const result = analyzeDelimiter([])
63
+ expect(result.delimiter).toBe('_')
64
+ expect(result.dominantFieldCount).toBe(1)
65
+ expect(result.minFieldCount).toBe(1)
66
+ expect(result.consistency).toBe(0)
67
+ })
68
+
69
+ it('should compute correct consistency ratio', () => {
70
+ // 3 out of 4 samples have 3 fields with underscore
71
+ const lines = ['A_B_C', 'D_E_F', 'G_H_I', 'JK']
72
+ const result = analyzeDelimiter(lines)
73
+ expect(result.delimiter).toBe('_')
74
+ expect(result.dominantFieldCount).toBe(3)
75
+ expect(result.consistency).toBe(0.75)
76
+ })
77
+
78
+ it('should return correct minFieldCount with mixed field counts', () => {
79
+ const lines = ['Control_Rep1', 'Treatment_Low_Rep1', 'Vehicle_Rep1']
80
+ const result = analyzeDelimiter(lines)
81
+ expect(result.delimiter).toBe('_')
82
+ expect(result.minFieldCount).toBe(2)
83
+ expect(result.dominantFieldCount).toBe(2)
84
+ })
85
+
86
+ it('should return minFieldCount different from dominantFieldCount when needed', () => {
87
+ const lines = [
88
+ 'Control_Rep1', 'Control_Rep2',
89
+ 'Treatment_Low_Rep1', 'Treatment_Low_Rep2', 'Treatment_Low_Rep3',
90
+ 'Treatment_High_Rep1', 'Treatment_High_Rep2', 'Treatment_High_Rep3',
91
+ ]
92
+ const result = analyzeDelimiter(lines)
93
+ expect(result.delimiter).toBe('_')
94
+ // Mode is 3 (6 samples have 3 fields vs 2 samples with 2 fields)
95
+ expect(result.dominantFieldCount).toBe(3)
96
+ // Min of multi-field counts is 2
97
+ expect(result.minFieldCount).toBe(2)
98
+ })
99
+ })
100
+
101
+ describe('detectOutliers', () => {
102
+ it('should flag samples with fewer fields than minFieldCount', () => {
103
+ const lines = ['Ctrl_WT_1', 'Ctrl_WT_2', 'QC_Pool', 'Treat_KO_1']
104
+ const outliers = detectOutliers(lines, '_', 3)
105
+ expect(outliers).toHaveLength(1)
106
+ expect(outliers[0].sample).toBe('QC_Pool')
107
+ expect(outliers[0].index).toBe(2)
108
+ expect(outliers[0].fieldCount).toBe(2)
109
+ expect(outliers[0].action).toBe('include')
110
+ })
111
+
112
+ it('should return empty array when all meet minFieldCount', () => {
113
+ const lines = ['A_B_C', 'D_E_F', 'G_H_I']
114
+ const outliers = detectOutliers(lines, '_', 3)
115
+ expect(outliers).toHaveLength(0)
116
+ })
117
+
118
+ it('should preserve original line index', () => {
119
+ const lines = ['A_B', 'C', 'D_E', 'F']
120
+ const outliers = detectOutliers(lines, '_', 2)
121
+ expect(outliers).toHaveLength(2)
122
+ expect(outliers[0]).toEqual({ sample: 'C', index: 1, fieldCount: 1, action: 'include' })
123
+ expect(outliers[1]).toEqual({ sample: 'F', index: 3, fieldCount: 1, action: 'include' })
124
+ })
125
+
126
+ it('should NOT flag samples with more fields than minFieldCount', () => {
127
+ const lines = ['Ctrl_Rep1', 'Treat_Low_Rep1']
128
+ const outliers = detectOutliers(lines, '_', 2)
129
+ expect(outliers).toHaveLength(0)
130
+ })
131
+
132
+ it('should flag single-segment samples when minFieldCount requires delimiter', () => {
133
+ const lines = ['Ctrl_Rep1', 'QCPool']
134
+ const outliers = detectOutliers(lines, '_', 2)
135
+ expect(outliers).toHaveLength(1)
136
+ expect(outliers[0].sample).toBe('QCPool')
137
+ expect(outliers[0].fieldCount).toBe(1)
138
+ })
139
+
140
+ it('should not flag any sample when minFieldCount is 1', () => {
141
+ const lines = ['ABC', 'DEF', 'G_H_I']
142
+ const outliers = detectOutliers(lines, '_', 1)
143
+ expect(outliers).toHaveLength(0)
144
+ })
145
+ })
146
+
147
+ describe('extractColumns', () => {
148
+ it('should produce correct column count and unique values via right alignment', () => {
149
+ const samples = ['Ctrl_Liver_1', 'Ctrl_Brain_2', 'Treat_Liver_1']
150
+ const columns = extractColumns(samples, '_', 3)
151
+ expect(columns).toHaveLength(3)
152
+ expect(columns[0].uniqueValues).toEqual(['Ctrl', 'Treat'])
153
+ expect(columns[1].uniqueValues).toEqual(['Liver', 'Brain'])
154
+ expect(columns[2].uniqueValues).toEqual(['1', '2'])
155
+ })
156
+
157
+ it('should compute correct cardinality', () => {
158
+ const samples = ['A_X_1', 'A_Y_2', 'B_X_1', 'B_Y_2']
159
+ const columns = extractColumns(samples, '_', 3)
160
+ expect(columns[0].cardinality).toBe(2) // A, B
161
+ expect(columns[1].cardinality).toBe(2) // X, Y
162
+ expect(columns[2].cardinality).toBe(2) // 1, 2
163
+ })
164
+
165
+ it('should assign Condition as first column name and Field N for rest', () => {
166
+ const samples = ['A_B_C']
167
+ const columns = extractColumns(samples, '_', 3)
168
+ expect(columns[0].name).toBe('Condition')
169
+ expect(columns[1].name).toBe('Field 2')
170
+ expect(columns[2].name).toBe('Field 3')
171
+ })
172
+
173
+ it('should set correct column indices', () => {
174
+ const samples = ['A_B_C_D']
175
+ const columns = extractColumns(samples, '_', 4)
176
+ expect(columns.map(c => c.index)).toEqual([0, 1, 2, 3])
177
+ })
178
+
179
+ it('should handle empty input', () => {
180
+ const columns = extractColumns([], '_', 2)
181
+ expect(columns).toHaveLength(0)
182
+ })
183
+
184
+ it('should handle variable-length prefixes via right alignment', () => {
185
+ const samples = ['Control_Rep1', 'Treatment_Low_Rep1', 'Vehicle_Rep1']
186
+ const columns = extractColumns(samples, '_', 2)
187
+ expect(columns).toHaveLength(2)
188
+ expect(columns[0].uniqueValues).toEqual(['Control', 'Treatment_Low', 'Vehicle'])
189
+ expect(columns[1].uniqueValues).toEqual(['Rep1'])
190
+ })
191
+
192
+ it('should right-align with 3+ suffix fields', () => {
193
+ const samples = ['A_X_1', 'B_C_X_1']
194
+ const columns = extractColumns(samples, '_', 3)
195
+ expect(columns).toHaveLength(3)
196
+ expect(columns[0].uniqueValues).toEqual(['A', 'B_C'])
197
+ expect(columns[1].uniqueValues).toEqual(['X'])
198
+ expect(columns[2].uniqueValues).toEqual(['1'])
199
+ })
200
+
201
+ it('should mark column types (prefix vs suffix)', () => {
202
+ const samples = ['A_B_C']
203
+ const columns = extractColumns(samples, '_', 3)
204
+ expect(columns[0].type).toBe('prefix')
205
+ expect(columns[1].type).toBe('suffix')
206
+ expect(columns[2].type).toBe('suffix')
207
+ })
208
+ })
209
+
210
+ describe('parseCSVLine', () => {
211
+ it('should parse simple comma-separated line', () => {
212
+ expect(parseCSVLine('A,B,C')).toEqual(['A', 'B', 'C'])
213
+ })
214
+
215
+ it('should handle quoted fields with commas', () => {
216
+ expect(parseCSVLine('A,"B,C",D')).toEqual(['A', 'B,C', 'D'])
217
+ })
218
+
219
+ it('should trim whitespace from fields', () => {
220
+ expect(parseCSVLine(' A , B , C ')).toEqual(['A', 'B', 'C'])
221
+ })
222
+
223
+ it('should parse tab-separated line when delimiter is tab', () => {
224
+ expect(parseCSVLine('A\tB\tC', '\t')).toEqual(['A', 'B', 'C'])
225
+ })
226
+ })
227
+
228
+ describe('parseCSV', () => {
229
+ it('should parse simple CSV with headers', () => {
230
+ const text = 'Sample,Condition,Tissue\nS1,Ctrl,Liver\nS2,Treat,Brain'
231
+ const result = parseCSV(text)
232
+ expect(result.columns).toEqual(['Sample', 'Condition', 'Tissue'])
233
+ expect(result.rows).toHaveLength(2)
234
+ expect(result.rows[0]).toEqual({ Sample: 'S1', Condition: 'Ctrl', Tissue: 'Liver' })
235
+ })
236
+
237
+ it('should auto-detect sample column by "sample" header', () => {
238
+ const text = 'Condition,Sample,Tissue\nCtrl,S1,Liver'
239
+ const result = parseCSV(text)
240
+ expect(result.sampleColumn).toBe('Sample')
241
+ })
242
+
243
+ it('should auto-detect sample column by "name" header', () => {
244
+ const text = 'Name,Group\nS1,A\nS2,B'
245
+ const result = parseCSV(text)
246
+ expect(result.sampleColumn).toBe('Name')
247
+ })
248
+
249
+ it('should auto-detect sample column by "id" header', () => {
250
+ const text = 'ID,Group\nS1,A'
251
+ const result = parseCSV(text)
252
+ expect(result.sampleColumn).toBe('ID')
253
+ })
254
+
255
+ it('should fall back to first column when no recognized header', () => {
256
+ const text = 'Foo,Bar,Baz\n1,2,3'
257
+ const result = parseCSV(text)
258
+ expect(result.sampleColumn).toBe('Foo')
259
+ })
260
+
261
+ it('should handle quoted fields with commas', () => {
262
+ const text = 'Sample,Description\nS1,"Long, complex name"\nS2,Simple'
263
+ const result = parseCSV(text)
264
+ expect(result.rows[0].Description).toBe('Long, complex name')
265
+ })
266
+
267
+ it('should reject CSV with fewer than 2 lines', () => {
268
+ expect(() => parseCSV('Header1,Header2')).toThrow()
269
+ expect(() => parseCSV('')).toThrow()
270
+ })
271
+
272
+ it('should auto-detect tab-separated files', () => {
273
+ const text = 'File Name\tTreatment Group\nSample_001\tCtrl\nSample_002\tTreat'
274
+ const result = parseCSV(text)
275
+ expect(result.columns).toEqual(['File Name', 'Treatment Group'])
276
+ expect(result.rows).toHaveLength(2)
277
+ expect(result.rows[0]).toEqual({ 'File Name': 'Sample_001', 'Treatment Group': 'Ctrl' })
278
+ expect(result.sampleColumn).toBe('File Name')
279
+ })
280
+
281
+ it('should auto-detect "file name" as sample column', () => {
282
+ const text = 'File Name,Group\nS1,A\nS2,B'
283
+ const result = parseCSV(text)
284
+ expect(result.sampleColumn).toBe('File Name')
285
+ })
286
+ })
287
+
288
+ describe('computeGroups', () => {
289
+ it('should group by single column', () => {
290
+ const samples = ['Ctrl_Liver_1', 'Ctrl_Brain_2', 'Treat_Liver_1']
291
+ const columns = extractColumns(samples, '_', 3)
292
+ const enabledFields = new Set([0]) // group by first column only
293
+
294
+ const result = computeGroups(samples, columns, enabledFields, new Map(), '_', 3)
295
+ expect(result.groups).toHaveLength(2)
296
+
297
+ const ctrlGroup = result.groups.find(g => g.name === 'Ctrl')
298
+ const treatGroup = result.groups.find(g => g.name === 'Treat')
299
+ expect(ctrlGroup?.samples).toEqual(['Ctrl_Liver_1', 'Ctrl_Brain_2'])
300
+ expect(treatGroup?.samples).toEqual(['Treat_Liver_1'])
301
+ })
302
+
303
+ it('should group by multiple columns joined with " / "', () => {
304
+ const samples = ['Ctrl_Liver_1', 'Ctrl_Brain_2', 'Treat_Liver_1']
305
+ const columns = extractColumns(samples, '_', 3)
306
+ const enabledFields = new Set([0, 1])
307
+
308
+ const result = computeGroups(samples, columns, enabledFields, new Map(), '_', 3)
309
+ expect(result.groups).toHaveLength(3)
310
+
311
+ const names = result.groups.map(g => g.name).sort()
312
+ expect(names).toEqual(['Ctrl / Brain', 'Ctrl / Liver', 'Treat / Liver'])
313
+ })
314
+
315
+ it('should cycle colors from DEFAULT_COLORS', () => {
316
+ const samples = Array.from({ length: 12 }, (_, i) => `G${i}_X`)
317
+ const columns = extractColumns(samples, '_', 2)
318
+ const enabledFields = new Set([0])
319
+
320
+ const result = computeGroups(samples, columns, enabledFields, new Map(), '_', 2)
321
+ // 12 unique groups should cycle through 10 colors
322
+ expect(result.groups[0].color).toBe(DEFAULT_COLORS[0])
323
+ expect(result.groups[10].color).toBe(DEFAULT_COLORS[0])
324
+ expect(result.groups[11].color).toBe(DEFAULT_COLORS[1])
325
+ })
326
+
327
+ it('should exclude outliers marked as "exclude"', () => {
328
+ const samples = ['Ctrl_WT_1', 'Ctrl_WT_2', 'QC_Pool']
329
+ const columns = extractColumns(['Ctrl_WT_1', 'Ctrl_WT_2'], '_', 3)
330
+ const enabledFields = new Set([0])
331
+ const outlierActions = new Map<number, OutlierAction>([[2, 'exclude']])
332
+
333
+ const result = computeGroups(samples, columns, enabledFields, outlierActions, '_', 3)
334
+ expect(result.excludedSamples).toContain('QC_Pool')
335
+ const allGroupedSamples = result.groups.flatMap(g => g.samples)
336
+ expect(allGroupedSamples).not.toContain('QC_Pool')
337
+ })
338
+
339
+ it('should put QC-marked outliers in a QC group', () => {
340
+ const samples = ['Ctrl_WT_1', 'Ctrl_WT_2', 'QC_Pool']
341
+ const columns = extractColumns(['Ctrl_WT_1', 'Ctrl_WT_2'], '_', 3)
342
+ const enabledFields = new Set([0])
343
+ const outlierActions = new Map<number, OutlierAction>([[2, 'qc']])
344
+
345
+ const result = computeGroups(samples, columns, enabledFields, outlierActions, '_', 3)
346
+ const qcGroup = result.groups.find(g => g.name === 'QC')
347
+ expect(qcGroup).toBeDefined()
348
+ expect(qcGroup?.samples).toContain('QC_Pool')
349
+ expect(qcGroup?.color).toBe('#6B7280')
350
+ })
351
+
352
+ it('should generate metadata rows', () => {
353
+ const samples = ['Ctrl_Liver_1', 'Treat_Brain_2']
354
+ const columns = extractColumns(samples, '_', 3)
355
+ // Rename columns for metadata
356
+ columns[0].name = 'Condition'
357
+ columns[1].name = 'Tissue'
358
+ columns[2].name = 'Replicate'
359
+ const enabledFields = new Set([0, 1])
360
+
361
+ const result = computeGroups(samples, columns, enabledFields, new Map(), '_', 3)
362
+ expect(result.metadata).toHaveLength(2)
363
+ expect(result.metadata[0]).toEqual({
364
+ sampleName: 'Ctrl_Liver_1',
365
+ fields: { Condition: 'Ctrl', Tissue: 'Liver', Replicate: '1' },
366
+ group: 'Ctrl / Liver',
367
+ })
368
+ })
369
+
370
+ it('should handle single column without separator in group name', () => {
371
+ const samples = ['A_1', 'B_2', 'A_3']
372
+ const columns = extractColumns(samples, '_', 2)
373
+ const enabledFields = new Set([0])
374
+
375
+ const result = computeGroups(samples, columns, enabledFields, new Map(), '_', 2)
376
+ const names = result.groups.map(g => g.name)
377
+ expect(names).toContain('A')
378
+ expect(names).toContain('B')
379
+ // Should NOT contain " / " separator
380
+ expect(names.every(n => !n.includes(' / '))).toBe(true)
381
+ })
382
+
383
+ it('should correctly group variable-length prefixes', () => {
384
+ const samples = [
385
+ 'Control_Rep1', 'Control_Rep2', 'Control_Rep3',
386
+ 'Treatment_Low_Rep1', 'Treatment_Low_Rep2', 'Treatment_Low_Rep3',
387
+ ]
388
+ const columns = extractColumns(samples, '_', 2)
389
+ const enabledFields = new Set([0])
390
+
391
+ const result = computeGroups(samples, columns, enabledFields, new Map(), '_', 2)
392
+ expect(result.groups).toHaveLength(2)
393
+
394
+ const ctrlGroup = result.groups.find(g => g.name === 'Control')
395
+ const treatGroup = result.groups.find(g => g.name === 'Treatment_Low')
396
+ expect(ctrlGroup?.samples).toHaveLength(3)
397
+ expect(treatGroup?.samples).toHaveLength(3)
398
+ })
399
+ })
400
+
401
+ describe('classifyOutlierAction', () => {
402
+ it('should return qc for samples containing QC keywords', () => {
403
+ expect(classifyOutlierAction('LT_13102025_EQC_Jurkat_1_2', '_')).toBe('qc')
404
+ expect(classifyOutlierAction('LT_13102025_IQC_Pool_1', '_')).toBe('qc')
405
+ expect(classifyOutlierAction('Blank_001', '_')).toBe('qc')
406
+ expect(classifyOutlierAction('STD_Low_1', '_')).toBe('qc')
407
+ expect(classifyOutlierAction('test_EQC_Jurkat_02', '_')).toBe('qc')
408
+ })
409
+
410
+ it('should match case-insensitively', () => {
411
+ expect(classifyOutlierAction('LT_eqc_Pool', '_')).toBe('qc')
412
+ expect(classifyOutlierAction('LT_EQC_Pool', '_')).toBe('qc')
413
+ expect(classifyOutlierAction('LT_Eqc_Pool', '_')).toBe('qc')
414
+ expect(classifyOutlierAction('BLANK_001', '_')).toBe('qc')
415
+ expect(classifyOutlierAction('Test_Sample', '_')).toBe('qc')
416
+ })
417
+
418
+ it('should match against individual segments only', () => {
419
+ // "eqc" embedded inside a segment should NOT match
420
+ expect(classifyOutlierAction('LT_SEQC123_Pool', '_')).toBe('include')
421
+ // "std" as standalone segment should match
422
+ expect(classifyOutlierAction('LT_std_Pool', '_')).toBe('qc')
423
+ })
424
+
425
+ it('should return include for regular experimental samples', () => {
426
+ expect(classifyOutlierAction('LT_13102025_212_WT_Glu', '_')).toBe('include')
427
+ expect(classifyOutlierAction('Control_Rep1', '_')).toBe('include')
428
+ expect(classifyOutlierAction('Treatment_High_Rep3', '_')).toBe('include')
429
+ })
430
+
431
+ it('should work with different delimiters', () => {
432
+ expect(classifyOutlierAction('LT-EQC-Pool', '-')).toBe('qc')
433
+ expect(classifyOutlierAction('blank.001', '.')).toBe('qc')
434
+ expect(classifyOutlierAction('Control-Rep1', '-')).toBe('include')
435
+ })
436
+ })
437
+
438
+ describe('computeGroupsFromCsv', () => {
439
+ it('should group samples by CSV column values', () => {
440
+ const csvData = parseCSV(
441
+ 'File Name\tTreatment Group\n' +
442
+ 'Sample_001\tCtrl\n' +
443
+ 'Sample_002\tCtrl\n' +
444
+ 'Sample_003\tTreat\n' +
445
+ 'Sample_004\tTreat\n'
446
+ )
447
+ const columns = [
448
+ { index: 0, name: 'Treatment Group', uniqueValues: ['Ctrl', 'Treat'], cardinality: 2 },
449
+ ]
450
+ const result = computeGroupsFromCsv(csvData, columns, new Set([0]))
451
+ expect(result.groups).toHaveLength(2)
452
+
453
+ const ctrl = result.groups.find(g => g.name === 'Ctrl')
454
+ const treat = result.groups.find(g => g.name === 'Treat')
455
+ expect(ctrl?.samples).toEqual(['Sample_001', 'Sample_002'])
456
+ expect(treat?.samples).toEqual(['Sample_003', 'Sample_004'])
457
+ expect(result.excludedSamples).toEqual([])
458
+ })
459
+
460
+ it('should group by multiple enabled CSV columns', () => {
461
+ const csvData = parseCSV(
462
+ 'Sample,Condition,Tissue\nS1,Ctrl,Liver\nS2,Ctrl,Brain\nS3,Treat,Liver'
463
+ )
464
+ const columns = [
465
+ { index: 0, name: 'Condition', uniqueValues: ['Ctrl', 'Treat'], cardinality: 2 },
466
+ { index: 1, name: 'Tissue', uniqueValues: ['Liver', 'Brain'], cardinality: 2 },
467
+ ]
468
+ const result = computeGroupsFromCsv(csvData, columns, new Set([0, 1]))
469
+ expect(result.groups).toHaveLength(3)
470
+
471
+ const names = result.groups.map(g => g.name).sort()
472
+ expect(names).toEqual(['Ctrl / Brain', 'Ctrl / Liver', 'Treat / Liver'])
473
+ })
474
+
475
+ it('should generate metadata rows from CSV data', () => {
476
+ const csvData = parseCSV(
477
+ 'Sample,Condition,Tissue\nS1,Ctrl,Liver\nS2,Treat,Brain'
478
+ )
479
+ const columns = [
480
+ { index: 0, name: 'Condition', uniqueValues: ['Ctrl', 'Treat'], cardinality: 2 },
481
+ { index: 1, name: 'Tissue', uniqueValues: ['Liver', 'Brain'], cardinality: 2 },
482
+ ]
483
+ const result = computeGroupsFromCsv(csvData, columns, new Set([0]))
484
+ expect(result.metadata).toHaveLength(2)
485
+ expect(result.metadata[0]).toEqual({
486
+ sampleName: 'S1',
487
+ fields: { Condition: 'Ctrl', Tissue: 'Liver' },
488
+ group: 'Ctrl',
489
+ })
490
+ })
491
+
492
+ it('should still group correctly after user renames a field', () => {
493
+ const csvData = parseCSV(
494
+ 'Sample,Condition,Tissue\nS1,Ctrl,Liver\nS2,Treat,Brain'
495
+ )
496
+ // Simulate user renaming "Condition" to "Treatment" in the Fields step
497
+ const columns = [
498
+ { index: 0, name: 'Treatment', originalName: 'Condition', uniqueValues: ['Ctrl', 'Treat'], cardinality: 2 },
499
+ { index: 1, name: 'Organ', originalName: 'Tissue', uniqueValues: ['Liver', 'Brain'], cardinality: 2 },
500
+ ]
501
+ const result = computeGroupsFromCsv(csvData, columns, new Set([0]))
502
+ expect(result.groups).toHaveLength(2)
503
+ expect(result.groups.find(g => g.name === 'Ctrl')?.samples).toEqual(['S1'])
504
+ expect(result.groups.find(g => g.name === 'Treat')?.samples).toEqual(['S2'])
505
+
506
+ // Metadata should use the display name as key
507
+ expect(result.metadata[0].fields['Treatment']).toBe('Ctrl')
508
+ expect(result.metadata[0].fields['Organ']).toBe('Liver')
509
+ })
510
+
511
+ it('should handle the real-world TSV format from the bug report', () => {
512
+ const text = [
513
+ 'File Name\tTreatment Group',
514
+ 'exp275_260401_YS_RNA_blank_001\tBlank',
515
+ 'exp275_260401_YS_RNA_SplusA1_002\tS+A',
516
+ 'exp275_260401_YS_RNA_A2_004\tA',
517
+ 'exp275_260401_YS_RNA_NC_005\tDigControl',
518
+ 'exp275_260401_YS_RNA_R2_007\tR',
519
+ 'exp275_260401_YS_RNA_S2_009\tS',
520
+ 'exp275_260401_YS_RNA_C1_010\tC',
521
+ 'exp275_260401_YS_RNA_RplusA1_011\tR+A',
522
+ ].join('\n')
523
+ const csvData = parseCSV(text)
524
+
525
+ expect(csvData.sampleColumn).toBe('File Name')
526
+ expect(csvData.columns).toEqual(['File Name', 'Treatment Group'])
527
+ expect(csvData.rows).toHaveLength(8)
528
+
529
+ const columns = [
530
+ { index: 0, name: 'Treatment Group', uniqueValues: ['Blank', 'S+A', 'A', 'DigControl', 'R', 'S', 'C', 'R+A'], cardinality: 8 },
531
+ ]
532
+ const result = computeGroupsFromCsv(csvData, columns, new Set([0]))
533
+ expect(result.groups).toHaveLength(8)
534
+
535
+ const blankGroup = result.groups.find(g => g.name === 'Blank')
536
+ expect(blankGroup?.samples).toEqual(['exp275_260401_YS_RNA_blank_001'])
537
+
538
+ const saGroup = result.groups.find(g => g.name === 'S+A')
539
+ expect(saGroup?.samples).toEqual(['exp275_260401_YS_RNA_SplusA1_002'])
540
+ })
541
+ })
542
+
543
+ describe('integration: mixed experimental + QC + test samples', () => {
544
+ // Simulates the real dataset pattern:
545
+ // 154 experimental (11 fields), ~15 QC (6-7 fields), 3 test (4 fields)
546
+ const experimental = [
547
+ 'LT_13102025_212_WT_Glu_3_20_S_091123_T1_33',
548
+ 'LT_13102025_213_WT_Glu_3_20_S_091123_T1_34',
549
+ 'LT_13102025_214_KI_Glu_3_20_S_091123_T1_35',
550
+ 'LT_13102025_215_KI_Glu_6_40_L_091123_T1_36',
551
+ 'LT_13102025_216_WT_Glu_6_40_L_091123_T1_37',
552
+ 'LT_13102025_217_WT_Glu_6_40_L_091123_T2_38',
553
+ 'LT_13102025_218_KI_Glu_3_20_S_091123_T2_39',
554
+ ]
555
+ const qcSamples = [
556
+ 'LT_13102025_EQC_Jurkat_1_2',
557
+ 'LT_13102025_IQC_Pool_3',
558
+ ]
559
+ const testSamples = [
560
+ 'test_EQC_Jurkat_02',
561
+ ]
562
+ const allLines = [...experimental, ...qcSamples, ...testSamples]
563
+
564
+ it('should detect dominantFieldCount=11 and flag QC/test as outliers', () => {
565
+ const analysis = analyzeDelimiter(allLines)
566
+ expect(analysis.delimiter).toBe('_')
567
+ expect(analysis.dominantFieldCount).toBe(11)
568
+
569
+ const outliers = detectOutliers(allLines, '_', analysis.dominantFieldCount)
570
+ // QC samples (6 fields) and test samples (4 fields) are outliers
571
+ expect(outliers).toHaveLength(qcSamples.length + testSamples.length)
572
+
573
+ // Experimental samples should NOT be flagged
574
+ const outlierIndices = new Set(outliers.map(o => o.index))
575
+ for (let i = 0; i < experimental.length; i++) {
576
+ expect(outlierIndices.has(i)).toBe(false)
577
+ }
578
+ })
579
+
580
+ it('should auto-classify QC/test outliers with smart defaults', () => {
581
+ const analysis = analyzeDelimiter(allLines)
582
+ const outliers = detectOutliers(allLines, '_', analysis.dominantFieldCount)
583
+
584
+ for (const outlier of outliers) {
585
+ outlier.action = classifyOutlierAction(outlier.sample, '_')
586
+ }
587
+
588
+ // All QC and test samples should be classified as 'qc'
589
+ expect(outliers.every(o => o.action === 'qc')).toBe(true)
590
+ })
591
+
592
+ it('should produce 11 columns from conforming samples', () => {
593
+ const analysis = analyzeDelimiter(allLines)
594
+ const outliers = detectOutliers(allLines, '_', analysis.dominantFieldCount)
595
+ const conforming = allLines.filter(
596
+ (_, i) => !outliers.some(o => o.index === i)
597
+ )
598
+
599
+ const conformingFieldCounts = conforming.map(s => s.split('_').length)
600
+ const effectiveMinFieldCount = Math.min(...conformingFieldCounts)
601
+
602
+ const columns = extractColumns(conforming, '_', effectiveMinFieldCount)
603
+ expect(columns).toHaveLength(11)
604
+ })
605
+
606
+ it('should auto-disable constant columns (cardinality 1)', () => {
607
+ const analysis = analyzeDelimiter(allLines)
608
+ const outliers = detectOutliers(allLines, '_', analysis.dominantFieldCount)
609
+ const conforming = allLines.filter(
610
+ (_, i) => !outliers.some(o => o.index === i)
611
+ )
612
+
613
+ const conformingFieldCounts = conforming.map(s => s.split('_').length)
614
+ const effectiveMinFieldCount = Math.min(...conformingFieldCounts)
615
+
616
+ const columns = extractColumns(conforming, '_', effectiveMinFieldCount)
617
+
618
+ // Auto-disable: only enable columns with cardinality > 1
619
+ const enabled = new Set(
620
+ columns.filter(f => f.cardinality > 1).map(f => f.index)
621
+ )
622
+
623
+ // Constant columns (LT, 13102025, Glu, 091123) should be disabled
624
+ const constantCols = columns.filter(c => c.cardinality === 1)
625
+ for (const col of constantCols) {
626
+ expect(enabled.has(col.index)).toBe(false)
627
+ }
628
+
629
+ // Variable columns should be enabled
630
+ const variableCols = columns.filter(c => c.cardinality > 1)
631
+ for (const col of variableCols) {
632
+ expect(enabled.has(col.index)).toBe(true)
633
+ }
634
+ expect(variableCols.length).toBeGreaterThan(0)
635
+ })
636
+ })
637
+
638
+ describe('extractSamplesFromDesignData', () => {
639
+ it('should extract samples with conditions into ParsedCsvData', () => {
640
+ const data = {
641
+ schema_version: '2.0',
642
+ samples: [
643
+ { sample_name: 'HeLa_DrugA_1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Drug A' } },
644
+ { sample_name: 'HeLa_Ctrl_1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Control' } },
645
+ { sample_name: 'MCF7_DrugA_1', sample_type: 'sample', conditions: { 'Cell Line': 'MCF7', Treatment: 'Drug A' } },
646
+ ],
647
+ }
648
+ const result = extractSamplesFromDesignData(data)
649
+ expect(result).not.toBeNull()
650
+ expect(result!.sampleColumn).toBe('sample_name')
651
+ expect(result!.columns).toEqual(['sample_name', 'Cell Line', 'Treatment'])
652
+ expect(result!.rows).toHaveLength(3)
653
+ expect(result!.rows[0]).toEqual({ sample_name: 'HeLa_DrugA_1', 'Cell Line': 'HeLa', Treatment: 'Drug A' })
654
+ })
655
+
656
+ it('should filter out QC and blank samples by sample_type', () => {
657
+ const data = {
658
+ samples: [
659
+ { sample_name: 'Sample_1', sample_type: 'sample', conditions: { Treatment: 'Drug A' } },
660
+ { sample_name: 'QC_Pool', sample_type: 'qc', conditions: { Treatment: 'QC' } },
661
+ { sample_name: 'Blank_1', sample_type: 'blank', conditions: {} },
662
+ { sample_name: 'Sample_2', sample_type: 'sample', conditions: { Treatment: 'Control' } },
663
+ ],
664
+ }
665
+ const result = extractSamplesFromDesignData(data)
666
+ expect(result).not.toBeNull()
667
+ expect(result!.rows).toHaveLength(2)
668
+ expect(result!.rows.map(r => r.sample_name)).toEqual(['Sample_1', 'Sample_2'])
669
+ })
670
+
671
+ it('should return null when no samples array exists', () => {
672
+ expect(extractSamplesFromDesignData({})).toBeNull()
673
+ expect(extractSamplesFromDesignData({ plates: [] })).toBeNull()
674
+ })
675
+
676
+ it('should return null when samples array is empty', () => {
677
+ expect(extractSamplesFromDesignData({ samples: [] })).toBeNull()
678
+ })
679
+
680
+ it('should return null when all samples are QC/blank', () => {
681
+ const data = {
682
+ samples: [
683
+ { sample_name: 'QC_1', sample_type: 'qc', conditions: { Treatment: 'QC' } },
684
+ { sample_name: 'Blank', sample_type: 'blank', conditions: {} },
685
+ ],
686
+ }
687
+ expect(extractSamplesFromDesignData(data)).toBeNull()
688
+ })
689
+
690
+ it('should return null when no samples have conditions', () => {
691
+ const data = {
692
+ samples: [
693
+ { sample_name: 'Sample_1', sample_type: 'sample', conditions: {} },
694
+ { sample_name: 'Sample_2', sample_type: 'sample' },
695
+ ],
696
+ }
697
+ expect(extractSamplesFromDesignData(data)).toBeNull()
698
+ })
699
+
700
+ it('should collect all condition keys across samples with mixed categories', () => {
701
+ const data = {
702
+ samples: [
703
+ { sample_name: 'S1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Drug A' } },
704
+ { sample_name: 'S2', sample_type: 'sample', conditions: { 'Cell Line': 'MCF7', Timepoint: '24h' } },
705
+ ],
706
+ }
707
+ const result = extractSamplesFromDesignData(data)
708
+ expect(result).not.toBeNull()
709
+ expect(result!.columns).toEqual(['sample_name', 'Cell Line', 'Treatment', 'Timepoint'])
710
+ // S1 has no Timepoint → empty string
711
+ expect(result!.rows[0]).toEqual({ sample_name: 'S1', 'Cell Line': 'HeLa', Treatment: 'Drug A', Timepoint: '' })
712
+ // S2 has no Treatment → empty string
713
+ expect(result!.rows[1]).toEqual({ sample_name: 'S2', 'Cell Line': 'MCF7', Treatment: '', Timepoint: '24h' })
714
+ })
715
+
716
+ it('should preserve column insertion order from first occurrence', () => {
717
+ const data = {
718
+ samples: [
719
+ { sample_name: 'S1', sample_type: 'sample', conditions: { Timepoint: '24h', Treatment: 'Drug' } },
720
+ { sample_name: 'S2', sample_type: 'sample', conditions: { Treatment: 'Ctrl', 'Cell Line': 'HeLa' } },
721
+ ],
722
+ }
723
+ const result = extractSamplesFromDesignData(data)
724
+ // Timepoint seen first in S1, then Treatment, then Cell Line from S2
725
+ expect(result!.columns).toEqual(['sample_name', 'Timepoint', 'Treatment', 'Cell Line'])
726
+ })
727
+
728
+ it('should handle samples without sample_type (defaults to "sample")', () => {
729
+ const data = {
730
+ samples: [
731
+ { sample_name: 'S1', conditions: { Treatment: 'Drug A' } },
732
+ { sample_name: 'S2', conditions: { Treatment: 'Control' } },
733
+ ],
734
+ }
735
+ const result = extractSamplesFromDesignData(data)
736
+ expect(result).not.toBeNull()
737
+ expect(result!.rows).toHaveLength(2)
738
+ })
739
+
740
+ it('should handle case-insensitive sample_type filtering', () => {
741
+ const data = {
742
+ samples: [
743
+ { sample_name: 'S1', sample_type: 'SAMPLE', conditions: { Treatment: 'Drug' } },
744
+ { sample_name: 'QC1', sample_type: 'QC', conditions: { Treatment: 'QC' } },
745
+ { sample_name: 'B1', sample_type: 'Blank', conditions: {} },
746
+ ],
747
+ }
748
+ const result = extractSamplesFromDesignData(data)
749
+ expect(result).not.toBeNull()
750
+ expect(result!.rows).toHaveLength(1)
751
+ expect(result!.rows[0].sample_name).toBe('S1')
752
+ })
753
+
754
+ it('should work with a realistic MS Designer design_data structure', () => {
755
+ const data = {
756
+ schema_version: '2.0',
757
+ cell_line: 'HeLa',
758
+ scheduled_date: '2026-04-10',
759
+ extraction_trigger: 'manual',
760
+ plates: [{ name: 'Plate 1', layout_type: '96-well' }],
761
+ samples: [
762
+ { plate_id: 'Plate 1', well_id: 'A1', sample_name: 'HeLa_DrugA_24h_Rep1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Drug A', Timepoint: '24h' } },
763
+ { plate_id: 'Plate 1', well_id: 'A2', sample_name: 'HeLa_DrugA_24h_Rep2', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Drug A', Timepoint: '24h' } },
764
+ { plate_id: 'Plate 1', well_id: 'A3', sample_name: 'HeLa_Ctrl_24h_Rep1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Control', Timepoint: '24h' } },
765
+ { plate_id: 'Plate 1', well_id: 'B1', sample_name: 'EQC_Pool_1', sample_type: 'qc', conditions: {} },
766
+ { plate_id: 'Plate 1', well_id: 'B2', sample_name: 'Blank_1', sample_type: 'blank', conditions: {} },
767
+ ],
768
+ extraction_results: [],
769
+ sequence_params: { polarity: 'pos' },
770
+ }
771
+ const result = extractSamplesFromDesignData(data)
772
+ expect(result).not.toBeNull()
773
+ expect(result!.rows).toHaveLength(3) // Only experimental samples
774
+ expect(result!.columns).toEqual(['sample_name', 'Cell Line', 'Treatment', 'Timepoint'])
775
+
776
+ // Verify it's compatible with computeGroupsFromCsv
777
+ const columns = result!.columns
778
+ .filter(c => c !== 'sample_name')
779
+ .map((col, i) => ({
780
+ index: i,
781
+ name: col,
782
+ originalName: col,
783
+ uniqueValues: [...new Set(result!.rows.map(r => r[col]))],
784
+ cardinality: new Set(result!.rows.map(r => r[col])).size,
785
+ }))
786
+
787
+ // Group by Treatment only
788
+ const groups = computeGroupsFromCsv(result!, columns, new Set([1])) // Treatment is index 1
789
+ expect(groups.groups).toHaveLength(2)
790
+ expect(groups.groups.find(g => g.name === 'Drug A')?.samples).toHaveLength(2)
791
+ expect(groups.groups.find(g => g.name === 'Control')?.samples).toHaveLength(1)
792
+ })
793
+ })
794
+
795
+ describe('useAutoGroup auto-disable degenerate columns', () => {
796
+ it('auto-disables unique-per-row columns in paste mode', () => {
797
+ const auto = useAutoGroup()
798
+ auto.rawText.value = [
799
+ 'Ctrl_WT_001',
800
+ 'Ctrl_WT_002',
801
+ 'Ctrl_WT_003',
802
+ 'Treat_WT_004',
803
+ 'Treat_WT_005',
804
+ ].join('\n')
805
+ auto.parseInput()
806
+
807
+ const lastCol = auto.fields.value.find(f => f.cardinality === 5)
808
+ expect(lastCol, 'expected a unique-per-row column').toBeTruthy()
809
+ expect(auto.enabledFields.value.has(lastCol!.index)).toBe(false)
810
+ // With the unique column auto-disabled, samples aggregate by Condition only
811
+ // (WT is constant, also auto-disabled): Ctrl=3 samples, Treat=2 samples.
812
+ expect(auto.groups.value).toHaveLength(2)
813
+ expect(auto.groups.value.every(g => g.samples.length > 1)).toBe(true)
814
+ })
815
+
816
+ it('auto-disables unique-per-row columns in CSV mode', () => {
817
+ const auto = useAutoGroup()
818
+ auto.inputMode.value = 'csv'
819
+ auto.csvData.value = parseCSV(
820
+ 'sample,condition,uid\n' +
821
+ 'S1,Ctrl,a1\n' +
822
+ 'S2,Ctrl,a2\n' +
823
+ 'S3,Treat,a3\n' +
824
+ 'S4,Treat,a4\n',
825
+ )
826
+ auto.parseInput()
827
+
828
+ const uidField = auto.fields.value.find(f => f.name === 'uid')
829
+ expect(uidField, 'expected uid column').toBeTruthy()
830
+ expect(auto.enabledFields.value.has(uidField!.index)).toBe(false)
831
+ })
832
+
833
+ it('keeps useful columns (cardinality between 2 and N-1) enabled', () => {
834
+ const auto = useAutoGroup()
835
+ auto.rawText.value = [
836
+ 'Ctrl_WT_Rep1',
837
+ 'Ctrl_WT_Rep2',
838
+ 'Ctrl_KO_Rep1',
839
+ 'Treat_WT_Rep1',
840
+ ].join('\n')
841
+ auto.parseInput()
842
+
843
+ const goodCols = auto.fields.value.filter(
844
+ f => f.cardinality > 1 && f.cardinality < auto.samples.value.length,
845
+ )
846
+ for (const col of goodCols) {
847
+ expect(auto.enabledFields.value.has(col.index)).toBe(true)
848
+ }
849
+ })
850
+
851
+ it('exposes allSingletons=true when every group has exactly one sample', () => {
852
+ const auto = useAutoGroup()
853
+ auto.rawText.value = ['A_x_1', 'B_y_2', 'C_z_3'].join('\n')
854
+ auto.parseInput()
855
+ auto.enabledFields.value = new Set(auto.fields.value.map(f => f.index))
856
+
857
+ expect(auto.groups.value.length).toBe(3)
858
+ expect(auto.allSingletons.value).toBe(true)
859
+ })
860
+ })