import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { useAutoGroup } from '../../../composables/useAutoGroup' // End-to-end defaults of the paste path: sample names in, group names and sizes // out. Fixtures `share-*.txt` are real batches from the lab's mass-spec share. function engineFor(samples: string[]) { const engine = useAutoGroup() engine.rawText.value = samples.join('\n') engine.parseInput() return engine } function sizes(groups: { name: string; samples: string[] }[] | undefined): Record { return Object.fromEntries((groups ?? []).map(group => [group.name, group.samples.length])) } const experimental = (samples: string[]) => sizes(engineFor(samples).result.value.experimentalGroups) const qc = (samples: string[]) => sizes(engineFor(samples).result.value.qcGroups) function fixture(name: string): string[] { return readFileSync(resolve(__dirname, `../../fixtures/auto-group/${name}.txt`), 'utf-8').trim().split('\n') } const cross = (a: string[], b: string[], c: string[], sep: string): string[] => a.flatMap(x => b.flatMap(y => c.map(z => [x, y, z].join(sep)))) const liverBatch = [ 'Ctrl_Liver_D1_Rep1', 'Ctrl_Liver_D1_Rep2', 'Treat_Liver_D3_Rep1', 'Treat_Liver_D3_Rep2', 'Batch_IQC_Rep1', 'Batch_IQC_Rep2', 'Batch_IQC_Rep3', ] const tracingBatch = [ 'LT_13102025_212_WT_Glu_3_20_S_091123_T1_33', 'LT_13102025_213_WT_Glu_3_20_S_091123_T1_34', 'LT_13102025_214_KI_Glu_3_20_S_091123_T1_35', 'LT_13102025_215_KI_Glu_6_40_L_091123_T1_36', 'LT_13102025_216_WT_Glu_6_40_L_091123_T1_37', 'LT_13102025_217_WT_Glu_6_40_L_091123_T2_38', 'LT_13102025_218_KI_Glu_3_20_S_091123_T2_39', 'LT_13102025_219_KI_Glu_6_40_L_091123_T2_40', 'LT_13102025_220_WT_Glu_3_20_S_091123_T2_41', 'LT_13102025_221_WT_Glu_6_40_L_091123_T2_42', 'LT_13102025_EQC_Jurkat_1_2', 'LT_13102025_EQC_Jurkat_2_8', 'LT_13102025_IQC_Pool_1_5', 'test_EQC_Jurkat_02', 'test_Blank_Run_01', ] const twoMatrices = [ 'Ctrl_Liver_D1_Rep1', 'Ctrl_Liver_D1_Rep2', 'Treat_Liver_D3_Rep1', 'Treat_Liver_D3_Rep2', 'QC_Pool', 'Blank', 'ISTD_Mix', 'Ctrl_Brain_D1_Rep1', 'Ctrl_Brain_D1_Rep2', ] describe('auto-group defaults: replicates', () => { it('should keep a trailing counter out of the group key when it repeats', () => { expect(experimental(cross(['WT', 'KO'], ['Vehicle', 'Drug'], ['1', '2', '3'], '-'))).toEqual({ 'WT / Vehicle': 3, 'WT / Drug': 3, 'KO / Vehicle': 3, 'KO / Drug': 3, }) }) it('should keep a trailing letter counter out of the group key when it repeats', () => { expect(experimental(cross(['WT', 'KO'], ['Vehicle', 'Drug'], ['A', 'B', 'C'], '_'))).toEqual({ 'WT / Vehicle': 3, 'WT / Drug': 3, 'KO / Vehicle': 3, 'KO / Drug': 3, }) }) it('should keep sample numbers, replicate tokens and injection numbers out of the group key when a name carries all three', () => { expect(experimental(tracingBatch)).toEqual({ 'WT / 3 / 20 / S': 3, 'KI / 3 / 20 / S': 2, 'KI / 6 / 40 / L': 2, 'WT / 6 / 40 / L': 3, }) }) it('should report original sample names in metadata when replicates collapse', () => { expect(engineFor(liverBatch).result.value.metadata.map(row => row.sampleName)).toEqual(liverBatch) }) it('should report original sample names as excluded when a class is excluded', () => { const engine = engineFor(liverBatch) engine.setClassDispositions(c => c.kind === 'iqc', 'exclude') expect(engine.result.value.excludedSamples).toEqual(['Batch_IQC_Rep1', 'Batch_IQC_Rep2', 'Batch_IQC_Rep3']) }) it('should count each QC replicate as a class member when QC names differ only by replicate', () => { const iqc = engineFor(liverBatch).classes.value.find(c => c.kind === 'iqc') expect(iqc?.members.length).toBe(3) }) it('should fill each pre-filled template row with the tokens of its own sample when replicates collapse', () => { const { content } = engineFor(liverBatch).composeTemplateForTest({ mode: 'prefilled', format: 'csv' }) const row = content.split('\n').find(line => line.startsWith('Treat_Liver_D3_Rep1,')) expect(row).toContain('Treat / D3') }) }) describe('auto-group defaults: classes', () => { it('should route bare QC and ISTD tokens to overlay classes when a name carries them', () => { expect(Object.keys(qc(twoMatrices)).sort()).toEqual(['Blank', 'IQC', 'Standard']) }) it('should start each group name with the class layer when the batch has two matrices', () => { expect(experimental(twoMatrices)).toEqual({ 'Liver / Ctrl / D1': 2, 'Liver / Treat / D3': 2, 'Brain': 2 }) }) it('should keep one overlay group when a QC class has two token lengths', () => { expect(qc(tracingBatch)).toEqual({ EQC: 3, IQC: 1, Blank: 1 }) }) it('should not name a column Username when it holds two genotypes', () => { const engine = engineFor(cross(['WT', 'KO'], ['Vehicle', 'Drug'], ['1', '2', '3'], '-')) expect(engine.activeSchema.value?.columns.map(column => column.name)).not.toContain('Username') }) }) describe('auto-group defaults: tokenizer', () => { const pairs = { WT: 2, KO: 2 } it('should drop a file extension when all names share it', () => { expect(experimental(['WT_Ctrl_01.raw', 'WT_Ctrl_02.raw', 'KO_Ctrl_03.raw', 'KO_Ctrl_04.raw'])).toEqual(pairs) }) it('should split on spaces when no other delimiter is present', () => { expect(experimental(['WT Ctrl 1', 'WT Ctrl 2', 'KO Ctrl 1', 'KO Ctrl 2'])).toEqual(pairs) }) it('should read one delimiter when it repeats', () => { expect(experimental(['WT__Ctrl_1', 'WT__Ctrl_2', 'KO__Ctrl_1', 'KO__Ctrl_2'])).toEqual(pairs) }) it('should report the experiment header when all names share it', () => { expect(engineFor(fixture('share-exp031')).experiment.value).toEqual({ code: 'exp031', date: '2025-03-18', operator: 'SU', }) }) }) describe('auto-group defaults: lab share batches', () => { it('should group exp031 into 8 conditions when the replicate is a letter', () => { expect(experimental(fixture('share-exp031'))).toEqual({ '105 / DMSO': 4, '105 / TFP': 4, '270 / DMSO': 4, '270 / TFP': 4, 'SF8628 / DMSO': 4, 'SF8628 / TFP': 4, 'SU-VI / DMSO': 4, 'SU-VI / TFP': 4, }) }) it('should group exp114 by condition when a token holds condition and replicate', () => { expect(experimental(fixture('share-exp114'))['HMW / 2']).toBe(3) }) it('should group exp214 by stem when a counter is attached to it', () => { expect(experimental(fixture('share-exp214'))['D1 / A']).toBe(3) }) it('should group exp021 by time point when it has more than five levels', () => { expect(Object.keys(experimental(fixture('share-exp021')))).toContain('inf2 / 1 / 0min') }) it('should keep the label token B13 as a column when exp021 is parsed', () => { const schemas = Object.values(engineFor(fixture('share-exp021')).schemas.value) expect(schemas.flatMap(schema => schema.columns.flatMap(column => column.uniqueValues))).toContain('B13') }) it('should read no QC class when the word is in the experiment title that all names share', () => { const names = cross(['WT', 'KO'], ['Vehicle', 'Drug'], ['1', '2', '3'], '_') .map(body => `exp137_250919_AK_betaine_std_HILIC_${body}`) expect(qc(names)).toEqual({}) }) }) describe('auto-group defaults: second review', () => { it('should keep a QC-only batch as one overlay when QC sits where the operator is expected', () => { expect(qc(['exp021_250101_QC_1', 'exp021_250101_QC_2'])).toEqual({ IQC: 2 }) }) it('should keep a QC-only batch as one overlay when all names share the QC token after the header', () => { expect(qc(['exp021_250101_SU_QC_1', 'exp021_250101_SU_QC_2'])).toEqual({ IQC: 2 }) }) it('should group by a bare-number dose when it is the last token of three samples', () => { expect(experimental(['Drug_1', 'Drug_5', 'Drug_10'])).toEqual({ '1': 1, '5': 1, '10': 1 }) }) it('should drop known file extensions when the names carry two different ones', () => { expect(experimental(['WT_Ctrl_1.raw', 'WT_Ctrl_2.mzML', 'KO_Ctrl_1.raw', 'KO_Ctrl_2.mzML'])).toEqual({ WT: 2, KO: 2 }) }) it('should leave a letter counter out of the group key when it sits before an unpadded injection number', () => { const names = cross(['WT', 'KO'], ['A', 'B', 'C'], ['x'], '_').map((name, i) => name.replace('_x', `_${i + 5}`)) expect(experimental(names)).toEqual({ WT: 3, KO: 3 }) }) })