@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.
- package/README.md +326 -0
- package/dist/__stories__/experiment-helpers.d.ts +25 -0
- package/dist/__tests__/components/AppLayout.test.d.ts +1 -0
- package/dist/__tests__/components/AppSidebar.test.d.ts +1 -0
- package/dist/__tests__/components/AppTopBar.test.d.ts +1 -0
- package/dist/__tests__/components/BaseInput.test.d.ts +1 -0
- package/dist/__tests__/components/BasePill.test.d.ts +1 -0
- package/dist/__tests__/components/Calendar.test.d.ts +1 -0
- package/dist/__tests__/components/CollapsibleCard.test.d.ts +1 -0
- package/dist/__tests__/components/DataFrame.test.d.ts +1 -0
- package/dist/__tests__/components/DropdownButton.test.d.ts +1 -0
- package/dist/__tests__/composables/formBuilderRegistry.test.d.ts +1 -0
- package/dist/__tests__/composables/useAppExperiment.test.d.ts +1 -0
- package/dist/__tests__/composables/useAuth.test.d.ts +1 -0
- package/dist/__tests__/composables/useAutoGroup.test.d.ts +1 -0
- package/dist/__tests__/composables/useExperimentData.test.d.ts +13 -0
- package/dist/__tests__/composables/useExperimentSave.test.d.ts +1 -0
- package/dist/__tests__/composables/useForm.test.d.ts +1 -0
- package/dist/__tests__/composables/useFormBuilder.test.d.ts +1 -0
- package/dist/__tests__/composables/usePlatformContext.test.d.ts +1 -0
- package/dist/__tests__/composables/usePluginApi.test.d.ts +13 -0
- package/dist/__tests__/composables/usePluginConfig.test.d.ts +14 -0
- package/dist/__tests__/utils/color.test.d.ts +1 -0
- package/dist/auth-BYmxZdJl.js +297 -0
- package/dist/auth-BYmxZdJl.js.map +1 -0
- package/dist/components/AlertBox.vue.d.ts +34 -0
- package/dist/components/AppAvatarMenu.vue.d.ts +58 -0
- package/dist/components/AppContainer.vue.d.ts +28 -0
- package/dist/components/AppLayout.vue.d.ts +31 -0
- package/dist/components/AppPageSelector.vue.d.ts +43 -0
- package/dist/components/AppPillNav.vue.d.ts +11 -0
- package/dist/components/AppPluginSwitcher.vue.d.ts +38 -0
- package/dist/components/AppSidebar.vue.d.ts +47 -0
- package/dist/components/AppTopBar.vue.d.ts +111 -0
- package/dist/components/AuditTrail.vue.d.ts +38 -0
- package/dist/components/AutoGroupModal.vue.d.ts +124 -0
- package/dist/components/Avatar.vue.d.ts +14 -0
- package/dist/components/BaseButton.vue.d.ts +37 -0
- package/dist/components/BaseCheckbox.vue.d.ts +17 -0
- package/dist/components/BaseInput.vue.d.ts +34 -0
- package/dist/components/BaseModal.vue.d.ts +46 -0
- package/dist/components/BasePill.vue.d.ts +57 -0
- package/dist/components/BaseRadioGroup.vue.d.ts +21 -0
- package/dist/components/BaseSelect.vue.d.ts +20 -0
- package/dist/components/BaseSlider.vue.d.ts +22 -0
- package/dist/components/BaseTabs.vue.d.ts +14 -0
- package/dist/components/BaseTextarea.vue.d.ts +30 -0
- package/dist/components/BaseToggle.vue.d.ts +19 -0
- package/dist/components/BatchProgressList.vue.d.ts +43 -0
- package/dist/components/Breadcrumb.vue.d.ts +33 -0
- package/dist/components/Calendar.vue.d.ts +107 -0
- package/dist/components/ChartContainer.vue.d.ts +31 -0
- package/dist/components/ChemicalFormula.vue.d.ts +8 -0
- package/dist/components/CollapsibleCard.vue.d.ts +41 -0
- package/dist/components/ColorSlider.vue.d.ts +34 -0
- package/dist/components/ConcentrationInput.vue.d.ts +25 -0
- package/dist/components/ConfirmDialog.vue.d.ts +42 -0
- package/dist/components/DataFrame.vue.d.ts +107 -0
- package/dist/components/DatePicker.vue.d.ts +25 -0
- package/dist/components/DateTimePicker.vue.d.ts +30 -0
- package/dist/components/Divider.vue.d.ts +14 -0
- package/dist/components/DoseCalculator.vue.d.ts +19 -0
- package/dist/components/DropdownButton.vue.d.ts +47 -0
- package/dist/components/EmptyState.vue.d.ts +36 -0
- package/dist/components/ExperimentCodeBadge.vue.d.ts +14 -0
- package/dist/components/ExperimentDataViewer.vue.d.ts +29 -0
- package/dist/components/ExperimentPopover.vue.d.ts +32 -0
- package/dist/components/ExperimentSelectorModal.vue.d.ts +28 -0
- package/dist/components/ExperimentTimeline.vue.d.ts +44 -0
- package/dist/components/FileUploader.vue.d.ts +40 -0
- package/dist/components/FitPanel.vue.d.ts +46 -0
- package/dist/components/FormActions.vue.d.ts +33 -0
- package/dist/components/FormBuilder.vue.d.ts +287 -0
- package/dist/components/FormField.vue.d.ts +28 -0
- package/dist/components/FormFieldRenderer.vue.d.ts +31 -0
- package/dist/components/FormSection.vue.d.ts +43 -0
- package/dist/components/FormulaInput.vue.d.ts +25 -0
- package/dist/components/GroupAssigner.vue.d.ts +25 -0
- package/dist/components/GroupingModal.vue.d.ts +12 -0
- package/dist/components/IconButton.vue.d.ts +34 -0
- package/dist/components/LoadingSpinner.vue.d.ts +12 -0
- package/dist/components/MoleculeInput.vue.d.ts +27 -0
- package/dist/components/MultiSelect.vue.d.ts +19 -0
- package/dist/components/NumberInput.vue.d.ts +22 -0
- package/dist/components/PlateMapEditor.vue.d.ts +50 -0
- package/dist/components/ProgressBar.vue.d.ts +23 -0
- package/dist/components/ProtocolStepEditor.vue.d.ts +24 -0
- package/dist/components/RackEditor.vue.d.ts +40 -0
- package/dist/components/ReagentEditor.vue.d.ts +30 -0
- package/dist/components/ReagentList.vue.d.ts +32 -0
- package/dist/components/ResourceCard.vue.d.ts +50 -0
- package/dist/components/SampleHierarchyTree.vue.d.ts +26 -0
- package/dist/components/SampleLegend.vue.d.ts +32 -0
- package/dist/components/SampleSelector.vue.d.ts +29 -0
- package/dist/components/ScheduleCalendar.vue.d.ts +110 -0
- package/dist/components/ScientificNumber.vue.d.ts +14 -0
- package/dist/components/SegmentedControl.vue.d.ts +20 -0
- package/dist/components/SequenceInput.vue.d.ts +54 -0
- package/dist/components/SettingsButton.vue.d.ts +30 -0
- package/dist/components/SettingsModal.vue.d.ts +36 -0
- package/dist/components/Skeleton.vue.d.ts +11 -0
- package/dist/components/StatusIndicator.vue.d.ts +13 -0
- package/dist/components/StepWizard.vue.d.ts +65 -0
- package/dist/components/TagsInput.vue.d.ts +39 -0
- package/dist/components/ThemeToggle.vue.d.ts +7 -0
- package/dist/components/TimePicker.vue.d.ts +29 -0
- package/dist/components/TimeRangeInput.vue.d.ts +27 -0
- package/dist/components/ToastNotification.vue.d.ts +2 -0
- package/dist/components/Tooltip.vue.d.ts +35 -0
- package/dist/components/UnitInput.vue.d.ts +39 -0
- package/dist/components/WellEditPopup.vue.d.ts +25 -0
- package/dist/components/WellPlate.vue.d.ts +73 -0
- package/dist/components/index.d.ts +87 -0
- package/dist/components/index.js +3 -0
- package/dist/components-CKf-UpGi.js +15089 -0
- package/dist/components-CKf-UpGi.js.map +1 -0
- package/dist/composables/experiment-utils.d.ts +8 -0
- package/dist/composables/formBuilderRegistry.d.ts +13 -0
- package/dist/composables/index.d.ts +28 -0
- package/dist/composables/index.js +3 -0
- package/dist/composables/useApi.d.ts +20 -0
- package/dist/composables/useAppExperiment.d.ts +37 -0
- package/dist/composables/useAsync.d.ts +128 -0
- package/dist/composables/useAuth.d.ts +47 -0
- package/dist/composables/useAutoGroup.d.ts +106 -0
- package/dist/composables/useChemicalFormula.d.ts +21 -0
- package/dist/composables/useConcentrationUnits.d.ts +29 -0
- package/dist/composables/useDoseCalculator.d.ts +58 -0
- package/dist/composables/useExperimentData.d.ts +18 -0
- package/dist/composables/useExperimentSave.d.ts +36 -0
- package/dist/composables/useExperimentSelector.d.ts +30 -0
- package/dist/composables/useForm.d.ts +92 -0
- package/dist/composables/useFormBuilder.d.ts +24 -0
- package/dist/composables/usePasskey.d.ts +10 -0
- package/dist/composables/usePlatformContext.d.ts +131 -0
- package/dist/composables/usePluginApi.d.ts +29 -0
- package/dist/composables/usePluginConfig.d.ts +13 -0
- package/dist/composables/useProtocolTemplates.d.ts +44 -0
- package/dist/composables/useRackEditor.d.ts +31 -0
- package/dist/composables/useReagentSeries.d.ts +23 -0
- package/dist/composables/useScheduleDrag.d.ts +78 -0
- package/dist/composables/useSequenceUtils.d.ts +14 -0
- package/dist/composables/useTheme.d.ts +8 -0
- package/dist/composables/useTimeUtils.d.ts +29 -0
- package/dist/composables/useToast.d.ts +22 -0
- package/dist/composables/useWellPlateEditor.d.ts +33 -0
- package/dist/composables-D0QfFzq1.js +805 -0
- package/dist/composables-D0QfFzq1.js.map +1 -0
- package/dist/histoire.setup.d.ts +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +7 -0
- package/dist/install.d.ts +16 -0
- package/dist/install.js +23 -0
- package/dist/install.js.map +1 -0
- package/dist/stores/auth.d.ts +146 -0
- package/dist/stores/index.d.ts +2 -0
- package/dist/stores/index.js +2 -0
- package/dist/stores/settings.d.ts +75 -0
- package/dist/styles.css +29728 -0
- package/dist/tailwind.preset.d.ts +58 -0
- package/dist/tailwind.preset.js +66 -0
- package/dist/tailwind.preset.js.map +1 -0
- package/dist/types/auth.d.ts +42 -0
- package/dist/types/auto-group.d.ts +34 -0
- package/dist/types/components.d.ts +528 -0
- package/dist/types/form-builder.d.ts +167 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/index.js +0 -0
- package/dist/types/platform.d.ts +75 -0
- package/dist/useScheduleDrag-DAJueTbK.js +7181 -0
- package/dist/useScheduleDrag-DAJueTbK.js.map +1 -0
- package/dist/utils/color.d.ts +24 -0
- package/package.json +114 -0
- package/src/__stories__/experiment-helpers.ts +83 -0
- package/src/__tests__/components/AppLayout.test.ts +163 -0
- package/src/__tests__/components/AppSidebar.test.ts +292 -0
- package/src/__tests__/components/AppTopBar.test.ts +683 -0
- package/src/__tests__/components/BaseInput.test.ts +99 -0
- package/src/__tests__/components/BasePill.test.ts +291 -0
- package/src/__tests__/components/Calendar.test.ts +566 -0
- package/src/__tests__/components/CollapsibleCard.test.ts +524 -0
- package/src/__tests__/components/DataFrame.test.ts +767 -0
- package/src/__tests__/components/DropdownButton.test.ts +471 -0
- package/src/__tests__/composables/formBuilderRegistry.test.ts +187 -0
- package/src/__tests__/composables/useAppExperiment.test.ts +560 -0
- package/src/__tests__/composables/useAuth.test.ts +188 -0
- package/src/__tests__/composables/useAutoGroup.test.ts +860 -0
- package/src/__tests__/composables/useExperimentData.test.ts +127 -0
- package/src/__tests__/composables/useExperimentSave.test.ts +347 -0
- package/src/__tests__/composables/useForm.test.ts +205 -0
- package/src/__tests__/composables/useFormBuilder.test.ts +917 -0
- package/src/__tests__/composables/usePlatformContext.test.ts +116 -0
- package/src/__tests__/composables/usePluginApi.test.ts +81 -0
- package/src/__tests__/composables/usePluginConfig.test.ts +176 -0
- package/src/__tests__/utils/color.test.ts +96 -0
- package/src/components/AlertBox.story.vue +204 -0
- package/src/components/AlertBox.vue +88 -0
- package/src/components/AppAvatarMenu.story.vue +155 -0
- package/src/components/AppAvatarMenu.vue +184 -0
- package/src/components/AppContainer.story.vue +104 -0
- package/src/components/AppContainer.vue +34 -0
- package/src/components/AppLayout.story.vue +292 -0
- package/src/components/AppLayout.vue +75 -0
- package/src/components/AppPageSelector.vue +159 -0
- package/src/components/AppPillNav.vue +66 -0
- package/src/components/AppPluginSwitcher.vue +241 -0
- package/src/components/AppSidebar.story.vue +309 -0
- package/src/components/AppSidebar.vue +119 -0
- package/src/components/AppTopBar.story.vue +304 -0
- package/src/components/AppTopBar.vue +661 -0
- package/src/components/AuditTrail.story.vue +163 -0
- package/src/components/AuditTrail.vue +151 -0
- package/src/components/AutoGroupModal.story.vue +273 -0
- package/src/components/AutoGroupModal.vue +566 -0
- package/src/components/Avatar.story.vue +115 -0
- package/src/components/Avatar.vue +79 -0
- package/src/components/BaseButton.story.vue +96 -0
- package/src/components/BaseButton.vue +73 -0
- package/src/components/BaseCheckbox.story.vue +73 -0
- package/src/components/BaseCheckbox.vue +69 -0
- package/src/components/BaseInput.story.vue +98 -0
- package/src/components/BaseInput.vue +74 -0
- package/src/components/BaseModal.story.vue +237 -0
- package/src/components/BaseModal.vue +182 -0
- package/src/components/BasePill.story.vue +142 -0
- package/src/components/BasePill.vue +89 -0
- package/src/components/BaseRadioGroup.story.vue +145 -0
- package/src/components/BaseRadioGroup.vue +124 -0
- package/src/components/BaseSelect.story.vue +120 -0
- package/src/components/BaseSelect.vue +71 -0
- package/src/components/BaseSlider.story.vue +122 -0
- package/src/components/BaseSlider.vue +126 -0
- package/src/components/BaseTabs.story.vue +127 -0
- package/src/components/BaseTabs.vue +59 -0
- package/src/components/BaseTextarea.story.vue +91 -0
- package/src/components/BaseTextarea.vue +62 -0
- package/src/components/BaseToggle.story.vue +81 -0
- package/src/components/BaseToggle.vue +76 -0
- package/src/components/BatchProgressList.story.vue +92 -0
- package/src/components/BatchProgressList.vue +184 -0
- package/src/components/Breadcrumb.story.vue +106 -0
- package/src/components/Breadcrumb.vue +75 -0
- package/src/components/Calendar.story.vue +106 -0
- package/src/components/Calendar.vue +363 -0
- package/src/components/ChartContainer.story.vue +113 -0
- package/src/components/ChartContainer.vue +64 -0
- package/src/components/ChemicalFormula.story.vue +102 -0
- package/src/components/ChemicalFormula.vue +39 -0
- package/src/components/CollapsibleCard.story.vue +135 -0
- package/src/components/CollapsibleCard.vue +167 -0
- package/src/components/ColorSlider.story.vue +120 -0
- package/src/components/ColorSlider.vue +164 -0
- package/src/components/ConcentrationInput.story.vue +77 -0
- package/src/components/ConcentrationInput.vue +185 -0
- package/src/components/ConfirmDialog.story.vue +248 -0
- package/src/components/ConfirmDialog.vue +93 -0
- package/src/components/DataFrame.story.vue +148 -0
- package/src/components/DataFrame.vue +419 -0
- package/src/components/DatePicker.story.vue +119 -0
- package/src/components/DatePicker.vue +330 -0
- package/src/components/DateTimePicker.story.vue +112 -0
- package/src/components/DateTimePicker.vue +392 -0
- package/src/components/Divider.story.vue +80 -0
- package/src/components/Divider.vue +49 -0
- package/src/components/DoseCalculator.story.vue +68 -0
- package/src/components/DoseCalculator.vue +476 -0
- package/src/components/DropdownButton.story.vue +102 -0
- package/src/components/DropdownButton.vue +181 -0
- package/src/components/EmptyState.story.vue +135 -0
- package/src/components/EmptyState.vue +69 -0
- package/src/components/ExperimentCodeBadge.story.vue +77 -0
- package/src/components/ExperimentCodeBadge.vue +64 -0
- package/src/components/ExperimentDataViewer.story.vue +174 -0
- package/src/components/ExperimentDataViewer.vue +288 -0
- package/src/components/ExperimentPopover.story.vue +384 -0
- package/src/components/ExperimentPopover.vue +241 -0
- package/src/components/ExperimentSelectorModal.story.vue +391 -0
- package/src/components/ExperimentSelectorModal.vue +387 -0
- package/src/components/ExperimentTimeline.story.vue +161 -0
- package/src/components/ExperimentTimeline.vue +382 -0
- package/src/components/FileUploader.story.vue +107 -0
- package/src/components/FileUploader.vue +386 -0
- package/src/components/FitPanel.story.vue +125 -0
- package/src/components/FitPanel.vue +120 -0
- package/src/components/FormActions.vue +92 -0
- package/src/components/FormBuilder.vue +214 -0
- package/src/components/FormField.story.vue +132 -0
- package/src/components/FormField.vue +59 -0
- package/src/components/FormFieldRenderer.vue +58 -0
- package/src/components/FormSection.vue +90 -0
- package/src/components/FormulaInput.story.vue +96 -0
- package/src/components/FormulaInput.vue +125 -0
- package/src/components/GroupAssigner.story.vue +83 -0
- package/src/components/GroupAssigner.vue +284 -0
- package/src/components/GroupingModal.story.vue +52 -0
- package/src/components/GroupingModal.vue +422 -0
- package/src/components/IconButton.story.vue +135 -0
- package/src/components/IconButton.vue +73 -0
- package/src/components/LoadingSpinner.story.vue +70 -0
- package/src/components/LoadingSpinner.vue +50 -0
- package/src/components/MoleculeInput.story.vue +66 -0
- package/src/components/MoleculeInput.vue +426 -0
- package/src/components/MultiSelect.story.vue +132 -0
- package/src/components/MultiSelect.vue +118 -0
- package/src/components/NumberInput.story.vue +122 -0
- package/src/components/NumberInput.vue +160 -0
- package/src/components/PlateMapEditor.story.vue +92 -0
- package/src/components/PlateMapEditor.vue +513 -0
- package/src/components/ProgressBar.story.vue +148 -0
- package/src/components/ProgressBar.vue +114 -0
- package/src/components/ProtocolStepEditor.story.vue +69 -0
- package/src/components/ProtocolStepEditor.vue +522 -0
- package/src/components/RackEditor.story.vue +100 -0
- package/src/components/RackEditor.vue +371 -0
- package/src/components/ReagentEditor.story.vue +153 -0
- package/src/components/ReagentEditor.vue +418 -0
- package/src/components/ReagentList.story.vue +137 -0
- package/src/components/ReagentList.vue +463 -0
- package/src/components/ResourceCard.story.vue +150 -0
- package/src/components/ResourceCard.vue +161 -0
- package/src/components/SampleHierarchyTree.story.vue +161 -0
- package/src/components/SampleHierarchyTree.vue +256 -0
- package/src/components/SampleLegend.story.vue +91 -0
- package/src/components/SampleLegend.vue +119 -0
- package/src/components/SampleSelector.story.vue +111 -0
- package/src/components/SampleSelector.vue +1033 -0
- package/src/components/ScheduleCalendar.story.vue +195 -0
- package/src/components/ScheduleCalendar.vue +569 -0
- package/src/components/ScientificNumber.story.vue +127 -0
- package/src/components/ScientificNumber.vue +197 -0
- package/src/components/SegmentedControl.story.vue +132 -0
- package/src/components/SegmentedControl.vue +79 -0
- package/src/components/SequenceInput.story.vue +119 -0
- package/src/components/SequenceInput.vue +209 -0
- package/src/components/SettingsButton.story.vue +58 -0
- package/src/components/SettingsButton.vue +76 -0
- package/src/components/SettingsModal.story.vue +145 -0
- package/src/components/SettingsModal.vue +146 -0
- package/src/components/Skeleton.story.vue +141 -0
- package/src/components/Skeleton.vue +74 -0
- package/src/components/StatusIndicator.story.vue +99 -0
- package/src/components/StatusIndicator.vue +40 -0
- package/src/components/StepWizard.story.vue +155 -0
- package/src/components/StepWizard.vue +223 -0
- package/src/components/TagsInput.story.vue +155 -0
- package/src/components/TagsInput.vue +265 -0
- package/src/components/ThemeToggle.story.vue +36 -0
- package/src/components/ThemeToggle.vue +54 -0
- package/src/components/TimePicker.story.vue +96 -0
- package/src/components/TimePicker.vue +273 -0
- package/src/components/TimeRangeInput.story.vue +104 -0
- package/src/components/TimeRangeInput.vue +122 -0
- package/src/components/ToastNotification.story.vue +157 -0
- package/src/components/ToastNotification.vue +62 -0
- package/src/components/Tooltip.story.vue +138 -0
- package/src/components/Tooltip.vue +119 -0
- package/src/components/UnitInput.story.vue +194 -0
- package/src/components/UnitInput.vue +213 -0
- package/src/components/WellEditPopup.vue +234 -0
- package/src/components/WellPlate.story.vue +282 -0
- package/src/components/WellPlate.vue +830 -0
- package/src/components/index.ts +118 -0
- package/src/composables/experiment-utils.ts +57 -0
- package/src/composables/formBuilderRegistry.ts +79 -0
- package/src/composables/index.ts +140 -0
- package/src/composables/useApi.ts +167 -0
- package/src/composables/useAppExperiment.ts +159 -0
- package/src/composables/useAsync.ts +323 -0
- package/src/composables/useAuth.ts +445 -0
- package/src/composables/useAutoGroup.ts +641 -0
- package/src/composables/useChemicalFormula.ts +275 -0
- package/src/composables/useConcentrationUnits.ts +246 -0
- package/src/composables/useDoseCalculator.ts +370 -0
- package/src/composables/useExperimentData.ts +86 -0
- package/src/composables/useExperimentSave.ts +192 -0
- package/src/composables/useExperimentSelector.ts +292 -0
- package/src/composables/useForm.ts +416 -0
- package/src/composables/useFormBuilder.ts +383 -0
- package/src/composables/usePasskey.ts +216 -0
- package/src/composables/usePlatformContext.ts +299 -0
- package/src/composables/usePluginApi.ts +39 -0
- package/src/composables/usePluginConfig.ts +93 -0
- package/src/composables/useProtocolTemplates.ts +518 -0
- package/src/composables/useRackEditor.ts +222 -0
- package/src/composables/useReagentSeries.ts +91 -0
- package/src/composables/useScheduleDrag.ts +245 -0
- package/src/composables/useSequenceUtils.ts +105 -0
- package/src/composables/useTheme.ts +58 -0
- package/src/composables/useTimeUtils.ts +131 -0
- package/src/composables/useToast.ts +40 -0
- package/src/composables/useWellPlateEditor.ts +421 -0
- package/src/histoire.setup.ts +17 -0
- package/src/index.ts +367 -0
- package/src/install.ts +32 -0
- package/src/stores/auth.ts +152 -0
- package/src/stores/index.ts +2 -0
- package/src/stores/settings.ts +218 -0
- package/src/styles/components/alert-box.css +150 -0
- package/src/styles/components/app-avatar-menu.css +155 -0
- package/src/styles/components/app-container.css +33 -0
- package/src/styles/components/app-layout.css +98 -0
- package/src/styles/components/app-page-selector.css +191 -0
- package/src/styles/components/app-pill-nav.css +57 -0
- package/src/styles/components/app-plugin-switcher.css +209 -0
- package/src/styles/components/app-sidebar.css +145 -0
- package/src/styles/components/app-top-bar.css +492 -0
- package/src/styles/components/audit-trail.css +143 -0
- package/src/styles/components/auto-group-modal.css +644 -0
- package/src/styles/components/avatar.css +73 -0
- package/src/styles/components/batch-progress-list.css +196 -0
- package/src/styles/components/breadcrumb.css +64 -0
- package/src/styles/components/button.css +188 -0
- package/src/styles/components/calendar.css +192 -0
- package/src/styles/components/chart-container.css +69 -0
- package/src/styles/components/checkbox.css +123 -0
- package/src/styles/components/chemical-formula.css +46 -0
- package/src/styles/components/collapsible-card.css +253 -0
- package/src/styles/components/color-slider.css +110 -0
- package/src/styles/components/concentration-input.css +156 -0
- package/src/styles/components/confirm-dialog.css +183 -0
- package/src/styles/components/dataframe.css +382 -0
- package/src/styles/components/date-picker.css +243 -0
- package/src/styles/components/datetime-picker.css +229 -0
- package/src/styles/components/divider.css +63 -0
- package/src/styles/components/dose-calculator.css +301 -0
- package/src/styles/components/dropdown-button.css +280 -0
- package/src/styles/components/empty-state.css +151 -0
- package/src/styles/components/experiment-code-badge.css +33 -0
- package/src/styles/components/experiment-data-viewer.css +138 -0
- package/src/styles/components/experiment-popover.css +562 -0
- package/src/styles/components/experiment-selector-modal.css +285 -0
- package/src/styles/components/experiment-timeline.css +529 -0
- package/src/styles/components/file-uploader.css +310 -0
- package/src/styles/components/fit-panel.css +67 -0
- package/src/styles/components/form-builder.css +69 -0
- package/src/styles/components/form-field.css +48 -0
- package/src/styles/components/formula-input.css +103 -0
- package/src/styles/components/group-assigner.css +200 -0
- package/src/styles/components/grouping-modal.css +323 -0
- package/src/styles/components/icon-button.css +192 -0
- package/src/styles/components/input.css +66 -0
- package/src/styles/components/loading-spinner.css +67 -0
- package/src/styles/components/modal.css +350 -0
- package/src/styles/components/molecule-input.css +186 -0
- package/src/styles/components/multi-select.css +131 -0
- package/src/styles/components/number-input.css +199 -0
- package/src/styles/components/pill.css +188 -0
- package/src/styles/components/plate-map-editor.css +464 -0
- package/src/styles/components/progress-bar.css +133 -0
- package/src/styles/components/protocol-step-editor.css +449 -0
- package/src/styles/components/rack-editor.css +265 -0
- package/src/styles/components/radio-group.css +240 -0
- package/src/styles/components/reagent-editor.css +510 -0
- package/src/styles/components/reagent-list.css +407 -0
- package/src/styles/components/resource-card.css +360 -0
- package/src/styles/components/sample-hierarchy-tree.css +314 -0
- package/src/styles/components/sample-legend.css +201 -0
- package/src/styles/components/sample-selector.css +751 -0
- package/src/styles/components/schedule-calendar.css +478 -0
- package/src/styles/components/scientific-number.css +63 -0
- package/src/styles/components/segmented-control.css +197 -0
- package/src/styles/components/select.css +77 -0
- package/src/styles/components/sequence-input.css +184 -0
- package/src/styles/components/settings-button.css +94 -0
- package/src/styles/components/settings-modal.css +95 -0
- package/src/styles/components/skeleton.css +49 -0
- package/src/styles/components/slider.css +74 -0
- package/src/styles/components/status-indicator.css +66 -0
- package/src/styles/components/step-wizard.css +192 -0
- package/src/styles/components/tabs.css +95 -0
- package/src/styles/components/tags-input.css +195 -0
- package/src/styles/components/textarea.css +82 -0
- package/src/styles/components/theme-toggle.css +69 -0
- package/src/styles/components/time-picker.css +171 -0
- package/src/styles/components/time-range-input.css +42 -0
- package/src/styles/components/toast.css +91 -0
- package/src/styles/components/toggle.css +146 -0
- package/src/styles/components/tooltip.css +91 -0
- package/src/styles/components/unit-input.css +123 -0
- package/src/styles/components/well-edit-popup.css +252 -0
- package/src/styles/components/well-plate.css +307 -0
- package/src/styles/index.css +87 -0
- package/src/styles/variables.css +1117 -0
- package/src/tailwind.preset.ts +61 -0
- package/src/types/auth.ts +55 -0
- package/src/types/auto-group.ts +40 -0
- package/src/types/components.ts +710 -0
- package/src/types/form-builder.ts +197 -0
- package/src/types/index.ts +207 -0
- package/src/types/platform.ts +116 -0
- 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
|
+
})
|