import { describe, it, expect, vi } from 'vitest' import { parseCSV, extractSamplesFromDesignData, useAutoGroup, } from '../../composables/useAutoGroup' import { classKey } from '../../composables/autoGroup/classKey' describe('parseCSV', () => { it('should parse simple CSV with headers', () => { const text = 'Sample,Condition,Tissue\nS1,Ctrl,Liver\nS2,Treat,Brain' const result = parseCSV(text) expect(result.columns).toEqual(['Sample', 'Condition', 'Tissue']) expect(result.rows).toHaveLength(2) expect(result.rows[0]).toEqual({ Sample: 'S1', Condition: 'Ctrl', Tissue: 'Liver' }) }) it('should auto-detect sample column by "sample" header', () => { const text = 'Condition,Sample,Tissue\nCtrl,S1,Liver' const result = parseCSV(text) expect(result.sampleColumn).toBe('Sample') }) it('should auto-detect sample column by "name" header', () => { const text = 'Name,Group\nS1,A\nS2,B' const result = parseCSV(text) expect(result.sampleColumn).toBe('Name') }) it('should auto-detect sample column by "id" header', () => { const text = 'ID,Group\nS1,A' const result = parseCSV(text) expect(result.sampleColumn).toBe('ID') }) it('should fall back to first column when no recognized header', () => { const text = 'Foo,Bar,Baz\n1,2,3' const result = parseCSV(text) expect(result.sampleColumn).toBe('Foo') }) it('should handle quoted fields with commas', () => { const text = 'Sample,Description\nS1,"Long, complex name"\nS2,Simple' const result = parseCSV(text) expect(result.rows[0].Description).toBe('Long, complex name') }) it('should reject CSV with fewer than 2 lines', () => { expect(() => parseCSV('Header1,Header2')).toThrow() expect(() => parseCSV('')).toThrow() }) it('should auto-detect tab-separated files', () => { const text = 'File Name\tTreatment Group\nSample_001\tCtrl\nSample_002\tTreat' const result = parseCSV(text) expect(result.columns).toEqual(['File Name', 'Treatment Group']) expect(result.rows).toHaveLength(2) expect(result.rows[0]).toEqual({ 'File Name': 'Sample_001', 'Treatment Group': 'Ctrl' }) expect(result.sampleColumn).toBe('File Name') }) it('should auto-detect "file name" as sample column', () => { const text = 'File Name,Group\nS1,A\nS2,B' const result = parseCSV(text) expect(result.sampleColumn).toBe('File Name') }) }) describe('extractSamplesFromDesignData', () => { it('should extract samples with conditions into ParsedCsvData', () => { const data = { schema_version: '2.0', samples: [ { sample_name: 'HeLa_DrugA_1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Drug A' } }, { sample_name: 'HeLa_Ctrl_1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Control' } }, { sample_name: 'MCF7_DrugA_1', sample_type: 'sample', conditions: { 'Cell Line': 'MCF7', Treatment: 'Drug A' } }, ], } const result = extractSamplesFromDesignData(data) expect(result).not.toBeNull() expect(result!.sampleColumn).toBe('sample_name') expect(result!.columns).toEqual(['sample_name', 'Cell Line', 'Treatment', 'sample_type']) expect(result!.rows).toHaveLength(3) expect(result!.rows[0]).toEqual({ sample_name: 'HeLa_DrugA_1', 'Cell Line': 'HeLa', Treatment: 'Drug A', sample_type: 'sample' }) }) it('should retain QC and blank samples and surface their type in sampleTypeHints', () => { const data = { samples: [ { sample_name: 'Sample_1', sample_type: 'sample', conditions: { Treatment: 'Drug A' } }, { sample_name: 'QC_Pool', sample_type: 'qc', conditions: { Treatment: 'QC' } }, { sample_name: 'Blank_1', sample_type: 'blank', conditions: {} }, { sample_name: 'Sample_2', sample_type: 'sample', conditions: { Treatment: 'Control' } }, ], } const result = extractSamplesFromDesignData(data) expect(result).not.toBeNull() // All 4 samples are retained — QC/blank are no longer filtered here. expect(result!.rows).toHaveLength(4) expect(result!.rows.map(r => r.sample_name)).toEqual(['Sample_1', 'QC_Pool', 'Blank_1', 'Sample_2']) // sampleTypeHints parallels the rows array. expect(result!.sampleTypeHints).toEqual(['sample', 'qc', 'blank', 'sample']) }) it('should return null when no samples array exists', () => { expect(extractSamplesFromDesignData({})).toBeNull() expect(extractSamplesFromDesignData({ plates: [] })).toBeNull() }) it('should return null when samples array is empty', () => { expect(extractSamplesFromDesignData({ samples: [] })).toBeNull() }) it('should retain all-QC/blank batches and set sampleTypeHints accordingly', () => { // QC_1 has conditions so allConditionKeys is non-empty; both samples are retained. const data = { samples: [ { sample_name: 'QC_1', sample_type: 'qc', conditions: { Treatment: 'QC' } }, { sample_name: 'Blank', sample_type: 'blank', conditions: {} }, ], } const result = extractSamplesFromDesignData(data) expect(result).not.toBeNull() expect(result!.rows).toHaveLength(2) expect(result!.sampleTypeHints).toEqual(['qc', 'blank']) }) it('should return null when no samples have conditions', () => { const data = { samples: [ { sample_name: 'Sample_1', sample_type: 'sample', conditions: {} }, { sample_name: 'Sample_2', sample_type: 'sample' }, ], } expect(extractSamplesFromDesignData(data)).toBeNull() }) it('should collect all condition keys across samples with mixed categories', () => { const data = { samples: [ { sample_name: 'S1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Drug A' } }, { sample_name: 'S2', sample_type: 'sample', conditions: { 'Cell Line': 'MCF7', Timepoint: '24h' } }, ], } const result = extractSamplesFromDesignData(data) expect(result).not.toBeNull() expect(result!.columns).toEqual(['sample_name', 'Cell Line', 'Treatment', 'Timepoint', 'sample_type']) // S1 has no Timepoint → empty string expect(result!.rows[0]).toEqual({ sample_name: 'S1', 'Cell Line': 'HeLa', Treatment: 'Drug A', Timepoint: '', sample_type: 'sample' }) // S2 has no Treatment → empty string expect(result!.rows[1]).toEqual({ sample_name: 'S2', 'Cell Line': 'MCF7', Treatment: '', Timepoint: '24h', sample_type: 'sample' }) }) it('should preserve column insertion order from first occurrence', () => { const data = { samples: [ { sample_name: 'S1', sample_type: 'sample', conditions: { Timepoint: '24h', Treatment: 'Drug' } }, { sample_name: 'S2', sample_type: 'sample', conditions: { Treatment: 'Ctrl', 'Cell Line': 'HeLa' } }, ], } const result = extractSamplesFromDesignData(data) // Timepoint seen first in S1, then Treatment, then Cell Line from S2; sample_type appended last. expect(result!.columns).toEqual(['sample_name', 'Timepoint', 'Treatment', 'Cell Line', 'sample_type']) }) it('should handle samples without sample_type (defaults to "sample")', () => { const data = { samples: [ { sample_name: 'S1', conditions: { Treatment: 'Drug A' } }, { sample_name: 'S2', conditions: { Treatment: 'Control' } }, ], } const result = extractSamplesFromDesignData(data) expect(result).not.toBeNull() expect(result!.rows).toHaveLength(2) }) it('should lower-case sample_type in sampleTypeHints regardless of input casing', () => { const data = { samples: [ { sample_name: 'S1', sample_type: 'SAMPLE', conditions: { Treatment: 'Drug' } }, { sample_name: 'QC1', sample_type: 'QC', conditions: { Treatment: 'QC' } }, { sample_name: 'B1', sample_type: 'Blank', conditions: {} }, ], } const result = extractSamplesFromDesignData(data) expect(result).not.toBeNull() // All 3 samples retained; hints are lower-cased. expect(result!.rows).toHaveLength(3) expect(result!.sampleTypeHints).toEqual(['sample', 'qc', 'blank']) }) it('should work with a realistic MS Designer design_data structure', () => { const data = { schema_version: '2.0', cell_line: 'HeLa', scheduled_date: '2026-04-10', extraction_trigger: 'manual', plates: [{ name: 'Plate 1', layout_type: '96-well' }], samples: [ { 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' } }, { 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' } }, { plate_id: 'Plate 1', well_id: 'A3', sample_name: 'HeLa_Ctrl_24h_Rep1', sample_type: 'sample', conditions: { 'Cell Line': 'HeLa', Treatment: 'Control', Timepoint: '24h' } }, { plate_id: 'Plate 1', well_id: 'B1', sample_name: 'EQC_Pool_1', sample_type: 'qc', conditions: {} }, { plate_id: 'Plate 1', well_id: 'B2', sample_name: 'Blank_1', sample_type: 'blank', conditions: {} }, ], extraction_results: [], sequence_params: { polarity: 'pos' }, } const result = extractSamplesFromDesignData(data) expect(result).not.toBeNull() // All 5 samples are retained (including QC and blank). expect(result!.rows).toHaveLength(5) expect(result!.columns).toEqual(['sample_name', 'Cell Line', 'Treatment', 'Timepoint', 'sample_type']) expect(result!.sampleTypeHints).toEqual(['sample', 'sample', 'sample', 'qc', 'blank']) }) }) describe('useAutoGroup CSV structured hierarchy', () => { it('uses CSV metadata columns as hierarchy without biological or QC class groups', () => { const auto = useAutoGroup() auto.inputMode.value = 'csv' auto.csvData.value = parseCSV([ 'Sample,Tissue,Treatment,sample_type', 'S1,Kidney,Drug,sample', 'S2,Liver,Drug,sample', 'QC_pool,,QC,qc', ].join('\n')) auto.parseInput() expect(auto.classes.value).toHaveLength(1) expect(auto.classes.value[0].kind).toBe('unknown') expect(auto.qcGroups.value).toEqual([]) expect(auto.groups.value.map(g => g.name).sort()).toEqual([ 'Kidney / Drug', 'Liver / Drug', 'Outlier', ]) expect(auto.groups.value.find(g => g.name === 'Outlier')?.samples).toEqual(['QC_pool']) expect(auto.groups.value.some(g => g.name.includes('Biological'))).toBe(false) expect(auto.groups.value.some(g => g.name === 'QC')).toBe(false) }) it('defaults every CSV metadata column into the group key', () => { const auto = useAutoGroup() auto.inputMode.value = 'csv' auto.csvData.value = parseCSV([ 'Sample,Batch,Tissue,Treatment,Timepoint', 'S1,B1,Kidney,Drug,5min', 'S2,B1,Kidney,Drug,10min', ].join('\n')) auto.parseInput() expect(auto.activeSchema.value?.columns.map(c => c.name)).toEqual([ 'Batch', 'Tissue', 'Treatment', 'Timepoint', ]) expect(auto.activeSchema.value?.groupBy).toEqual([0, 1, 2, 3]) expect(auto.groups.value.map(g => g.name).sort()).toEqual([ 'B1 / Kidney / Drug / 10min', 'B1 / Kidney / Drug / 5min', ]) }) it('limits experiment design-data grouping to provided sample names', () => { const auto = useAutoGroup() const loaded = auto.loadExperimentData({ samples: [ { sample_name: 'S1', sample_type: 'sample', conditions: { Tissue: 'Kidney', Treatment: 'Control' } }, { sample_name: 'S2', sample_type: 'sample', conditions: { Tissue: 'Liver', Treatment: 'Drug' } }, { sample_name: 'S3', sample_type: 'sample', conditions: { Tissue: 'Brain', Treatment: 'Drug' } }, ], }, { samples: ['S2', 'MissingFromDesign'] }) expect(loaded).toBe(true) expect(auto.samples.value).toEqual(['S2', 'MissingFromDesign']) expect(auto.groups.value.map(g => g.name).sort()).toEqual([ 'Liver / Drug', 'Outlier', ]) expect(auto.groups.value.find(g => g.name === 'Outlier')?.samples).toEqual(['MissingFromDesign']) }) }) describe('useAutoGroup auto-disable degenerate columns', () => { it('collapses unique-per-row injection numbers into one group per condition', () => { // The trailing injection number is detected as a run-order column and kept // OUT of the default group key, so the 5 samples collapse to two condition // groups (Ctrl, Treat) even though every injection number is distinct. const auto = useAutoGroup() auto.rawText.value = [ 'Ctrl_WT_001', 'Ctrl_WT_002', 'Ctrl_WT_003', 'Treat_WT_004', 'Treat_WT_005', ].join('\n') auto.parseInput() expect(auto.groups.value).toHaveLength(2) const ctrl = auto.groups.value.find(g => g.name === 'Ctrl') const treat = auto.groups.value.find(g => g.name === 'Treat') expect(ctrl?.samples).toHaveLength(3) expect(treat?.samples).toHaveLength(2) expect(auto.groups.value.every(g => g.samples.length > 1)).toBe(true) }) it('surfaces the injection number as a non-grouping run-order column', () => { // The trailing 3-digit injection number is now SURFACED as a column rather // than silently stripped: it appears in the active schema with role // 'run-order' and a high cardinality, but is excluded from the default // group key so it never fragments groups into singletons. const auto = useAutoGroup() auto.rawText.value = [ 'Ctrl_WT_001', 'Ctrl_WT_002', 'Ctrl_WT_003', 'Treat_WT_004', 'Treat_WT_005', ].join('\n') auto.parseInput() const schema = auto.activeSchema.value expect(schema).toBeTruthy() const injection = schema!.columns.find(c => c.role === 'run-order') expect(injection).toBeDefined() expect(injection!.cardinality).toBe(5) expect(injection!.name).toBe('Injection #') // Detected but NOT used for grouping. expect(auto.enabledFields.value.has(injection!.index)).toBe(false) // The Ctrl/Treat condition column IS used for grouping. const condition = schema!.columns.find(c => c.role === 'factor') expect(condition).toBeDefined() expect(auto.enabledFields.value.has(condition!.index)).toBe(true) }) it('keeps useful columns (cardinality between 2 and N-1) enabled', () => { const auto = useAutoGroup() auto.rawText.value = [ 'Ctrl_WT_Rep1', 'Ctrl_WT_Rep2', 'Ctrl_KO_Rep1', 'Treat_WT_Rep1', ].join('\n') auto.parseInput() // Replicate columns are visible fields too, but they are off by default. const goodCols = auto.fields.value.filter( f => f.role !== 'replicate' && f.cardinality > 1 && f.cardinality < auto.samples.value.length, ) for (const col of goodCols) { expect(auto.enabledFields.value.has(col.index)).toBe(true) } }) it('exposes allSingletons=true when every group has exactly one sample', () => { const auto = useAutoGroup() // All-unique samples: each is classified as unknown, all 3 columns are factor by default auto.rawText.value = ['A_x_1', 'B_y_2', 'C_z_3'].join('\n') auto.parseInput() // With all 3 factor columns in groupBy by default, each unique combination = singleton expect(auto.groups.value.length).toBe(3) expect(auto.allSingletons.value).toBe(true) }) }) describe('useAutoGroup token-length grouping', () => { it('separates same-type samples with different token-lengths into distinct classes and groups', () => { const auto = useAutoGroup() // All Plasma, but the first pair has 4 tokens and the second pair has 5. auto.rawText.value = [ 'study_Plasma_5min_001', 'study_Plasma_5min_002', 'study_Plasma_WT_10min_003', 'study_Plasma_WT_10min_004', ].join('\n') auto.parseInput() // One class per token-length, each tagged with its field count. const lengths = auto.classes.value.map(c => c.tokenLength).sort() expect(lengths).toEqual([4, 5]) expect(auto.classes.value.every(c => c.label.includes('fields'))).toBe(true) // Samples of different token-length never land in the same group. expect(auto.groups.value).toHaveLength(2) for (const g of auto.groups.value) { const tokenCounts = new Set(g.samples.map(s => s.split('_').length)) expect(tokenCounts.size).toBe(1) } }) }) describe('useAutoGroup — class-first orchestration', () => { it('detects classes after parseInput runs', async () => { const ag = useAutoGroup() ag.rawText.value = [ 'exp281_260415_SAM_SAH_Tissues_Kidney_M1_001', 'exp281_260415_SAM_SAH_Tissues_Liver_M1_002', 'exp281_260415_SAM_SAH_Tissues_IQC_003', ].join('\n') ag.parseInput() const kinds = ag.classes.value.map(c => c.kind).sort() expect(kinds).toContain('biological') expect(kinds).toContain('iqc') }) it('switches active schema when activeClassKey changes', () => { const ag = useAutoGroup() ag.rawText.value = [ 'a_Kidney_M1_001', 'b_Liver_M1_002', 'c_IQC_003', ].join('\n') ag.parseInput() const firstKey = classKey(ag.classes.value[0]) ag.activeClassKey.value = firstKey expect(ag.activeSchema.value!.classKind).toBe(ag.classes.value[0].kind) }) it('setClassDisposition rewrites disposition and re-derives qcGroups', () => { const ag = useAutoGroup() ag.rawText.value = [ 'exp_Plasma_M1_001', 'exp_Plasma_M2_002', 'exp_IQC_003', ].join('\n') ag.parseInput() const iqcKey = classKey(ag.classes.value.find(c => c.kind === 'iqc')!) ag.setClassDisposition(iqcKey, 'group') expect(ag.classes.value.find(c => c.kind === 'iqc')!.disposition).toBe('group') }) it('legacy setOutlierAction is a no-op deprecation shim', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const ag = useAutoGroup() ag.rawText.value = ['a_b_c'].join('\n') ag.parseInput() ag.setOutlierAction(0, 'exclude') expect(warn).toHaveBeenCalled() warn.mockRestore() }) it('preserves legacy public refs after redesign', () => { const ag = useAutoGroup() ag.rawText.value = 'a_b_c\nd_e_f' ag.parseInput() expect(ag.delimiter.value).toBeDefined() expect(ag.fields.value).toBeDefined() expect(ag.enabledFields.value).toBeDefined() expect(ag.effectiveColumns.value).toBeDefined() expect(ag.allSingletons.value).toBeDefined() }) it('downloadTemplate composes blank template before classifier ran', () => { const ag = useAutoGroup() ag.rawText.value = 'a_b\nc_d' ag.parseInput() const { content } = ag.composeTemplateForTest({ mode: 'blank', format: 'csv' }) expect(content.split('\n')[0]).toContain('sample_name') }) }) describe('useAutoGroup — regressions from token-length classKey change', () => { const raggedPlasma = [ 'study_Plasma_5min_001', 'study_Plasma_5min_002', 'study_Plasma_WT_10min_003', 'study_Plasma_WT_10min_004', ].join('\n') it('round-trips a fingerprint of length-split schemas without dropping groups', () => { const auto = useAutoGroup() auto.rawText.value = raggedPlasma auto.parseInput() const before = auto.groups.value.length expect(before).toBeGreaterThan(0) auto.loadFingerprint(auto.fingerprint.value) expect(auto.groups.value.length).toBe(before) }) it('fills prefilled-template schema-column cells for length-split classes', () => { const auto = useAutoGroup() auto.rawText.value = raggedPlasma auto.parseInput() const { content } = auto.composeTemplateForTest({ mode: 'prefilled', format: 'csv' }) const lines = content.trim().split('\n') const header = lines[0].split(',') // Columns live between 'class' (index 1) and the trailing 'group','notes'. const colStart = 2 const colEnd = header.length - 2 const dataRows = lines.slice(1).map(l => l.split(',')) // With the old key mismatch every schema-column cell was blank; the fix fills them. const anyFilled = dataRows.some(r => r.slice(colStart, colEnd).some(c => (c ?? '').trim().length > 0), ) expect(anyFilled).toBe(true) }) it('expands groupTree replicate base names back to original samples', () => { const auto = useAutoGroup() auto.rawText.value = [ 'study_Ctrl_Rep1', 'study_Ctrl_Rep2', 'study_Treat_Rep1', 'study_Treat_Rep2', ].join('\n') auto.parseInput() const tree = auto.result.value.groupTree ?? [] const leaves: string[] = [] const walk = (n: { label: string; children?: unknown[] }) => { const kids = n.children as Array | undefined if (kids && kids.length) kids.forEach(walk) else leaves.push(n.label) } tree.forEach(walk) expect(leaves.sort()).toEqual([ 'study_Ctrl_Rep1', 'study_Ctrl_Rep2', 'study_Treat_Rep1', 'study_Treat_Rep2', ]) }) })