import { describe, it, expect } from 'vitest' import { inferRole, parseNumeric, buildClassSchema, splitColumns } from '../../../composables/autoGroup/columns' describe('parseNumeric', () => { it('parses value+unit tokens', () => { expect(parseNumeric('5nM')).toEqual({ value: 5, unit: 'nM', raw: '5nM' }) expect(parseNumeric('400ulES')).toEqual({ value: 400, unit: 'ulES', raw: '400ulES' }) expect(parseNumeric('1ul')).toEqual({ value: 1, unit: 'ul', raw: '1ul' }) }) it('decodes the dot-encoded decimal point', () => { expect(parseNumeric('0dot5ul')).toEqual({ value: 0.5, unit: 'ul', raw: '0dot5ul' }) }) it('parses ratios via the dedicated branch', () => { expect(parseNumeric('1to100')).toEqual({ value: 0.01, unit: 'ratio', raw: '1to100' }) expect(parseNumeric('1to10')).toEqual({ value: 0.1, unit: 'ratio', raw: '1to10' }) }) it('rejects bare IDs, dates, and pure numbers', () => { expect(parseNumeric('M10')).toBeNull() expect(parseNumeric('exp281')).toBeNull() expect(parseNumeric('260415')).toBeNull() expect(parseNumeric('123')).toBeNull() }) }) describe('inferRole', () => { const make = (values: string[]) => ({ values, position: 0, totalPositions: 1, classSize: values.length, }) it('returns constant when every value is the same', () => { expect(inferRole(make(['exp281', 'exp281', 'exp281']))).toBe('constant') }) it('returns run-order when trailing position has high-card numeric values', () => { expect(inferRole({ values: ['001', '002', '003', '004', '005'], position: 4, totalPositions: 5, classSize: 5, })).toBe('run-order') }) it('returns numeric when many distinct value+unit values are present', () => { // 6+ distinct numeric levels read as numeric (binning candidates). expect(inferRole(make(['1nM', '5nM', '10nM', '20nM', '50nM', '100nM']))).toBe('numeric') }) it('returns factor for low-cardinality numerics (<=5 unique levels)', () => { // 5min / 10min and similar small dose ladders read as factors by default; // users opt into binning via the column popover for the cases that want it. expect(inferRole(make(['5min', '10min']))).toBe('factor') expect(inferRole(make(['5nM', '10nM', '20nM']))).toBe('factor') }) it('should return id when each sample has its own numbered value', () => { expect(inferRole({ values: ['M1', 'M2', 'M3', 'M4', 'M5'], position: 1, totalPositions: 3, classSize: 5, })).toBe('id') }) it('should return factor when a unique column is in a class of fewer than four samples', () => { expect(inferRole({ values: ['M1', 'M2', 'M3'], position: 1, totalPositions: 3, classSize: 3 })).toBe('factor') }) it('should return replicate when all values are T tokens', () => { expect(inferRole({ values: ['T1', 'T2', 'T1', 'T2'], position: 1, totalPositions: 3, classSize: 4 })).toBe('replicate') }) it('should return factor when the values are B label tokens', () => { expect(inferRole({ values: ['B13', 'S13', 'B13', 'S13'], position: 1, totalPositions: 3, classSize: 4 })).toBe('factor') }) it('should return replicate when a small counter repeats at the end of a name', () => { expect(inferRole({ values: ['1', '2', '3', '1', '2', '3'], position: 2, totalPositions: 3, classSize: 6 })).toBe('replicate') }) it('should return factor when a small number repeats in the middle of a name', () => { expect(inferRole({ values: ['1', '2', '3', '1', '2', '3'], position: 1, totalPositions: 3, classSize: 6 })).toBe('factor') }) it('should return replicate when all values are replicate-named tokens', () => { expect(inferRole({ values: ['rep1', 'rep2', 'rep3'], position: 1, totalPositions: 3, classSize: 3, })).toBe('replicate') expect(inferRole({ values: ['Replicate1', 'Replicate2'], position: 1, totalPositions: 3, classSize: 2, })).toBe('replicate') }) it('returns factor for low-cardinality categoricals', () => { expect(inferRole(make(['Kidney', 'Liver', 'Tumor', 'Kidney', 'Liver']))).toBe('factor') }) it('should return ignore when more than 50 values each repeat', () => { // Real-world LC-MS batches routinely have 30–50 distinct conditions, so the // factor cap is 50. 80 values that each occur twice are not a sample ID // and are too many for a factor. const values = Array.from({ length: 160 }, (_, i) => `opaque-${i % 80}`) expect(inferRole({ values, position: 0, totalPositions: 1, classSize: 160 })).toBe('ignore') }) it('should return id when 80 opaque values are all unique', () => { const values = Array.from({ length: 80 }, (_, i) => `opaque-${i}`) expect(inferRole({ values, position: 0, totalPositions: 1, classSize: 80 })).toBe('id') }) }) describe('splitColumns', () => { it('should split on the hyphen when a column holds condition-replicate tokens', () => { expect(splitColumns([['HMW', '1-1'], ['HMW', '1-2'], ['MMW', '2-1']]).rows[0]).toEqual(['HMW', '1', '1']) }) it('should split stem and counter when two stems repeat', () => { expect(splitColumns([['C1'], ['C2'], ['H1'], ['H2']]).rows[0]).toEqual(['C', '1']) }) it('should keep a token whole when the column has one stem', () => { expect(splitColumns([['D1'], ['D3'], ['D6'], ['D1']]).rows[0]).toEqual(['D1']) }) it('should keep a token whole when each stem has one counter', () => { expect(splitColumns([['B13'], ['S13'], ['B13'], ['S13']]).rows[0]).toEqual(['B13']) }) it('should pad a name when it does not match the split of its column', () => { const rows = [...Array.from({ length: 9 }, (_, i) => ['HMW', `${i + 1}-1`]), ['HMW', 'DigestControl']] expect(splitColumns(rows).rows[9]).toEqual(['HMW', 'DigestControl', '']) }) it('should report the source column of each new column when a column splits', () => { expect(splitColumns([['HMW', '1-1'], ['MMW', '2-2']]).origin).toEqual([0, 1, 1]) }) }) describe('parseNumeric decimals', () => { it('should read the value when the token has a decimal point', () => { expect(parseNumeric('0.1uM')).toEqual({ value: 0.1, unit: 'uM', raw: '0.1uM' }) }) }) describe('buildClassSchema', () => { it('builds one ColumnInfo per position, constant class-tag positions get class-tag role', () => { const schema = buildClassSchema( [ ['exp281', 'SAM_SAH', 'Tissues', 'Kidney', 'M10', '085'], ['exp281', 'SAM_SAH', 'Tissues', 'Liver', 'M10', '047'], ], 'biological', 'Tissues', [2, 3], // positions flagged as class-related tags ) expect(schema.classKind).toBe('biological') expect(schema.subKind).toBe('Tissues') // Position 2 ('Tissues') is constant → class-tag. // Position 3 ('Kidney'/'Liver') is multi-valued → falls through to factor inference. const tagCols = schema.columns.filter(c => c.role === 'class-tag') expect(tagCols.length).toBe(1) expect(tagCols[0].index).toBe(2) const factorCols = schema.columns.filter(c => c.role === 'factor') expect(factorCols.some(c => c.index === 3)).toBe(true) }) it('default groupBy is every Factor column', () => { const schema = buildClassSchema( [ ['exp', 'Kidney', '1'], ['exp', 'Liver', '2'], ['exp', 'Tumor', '3'], ], 'biological', 'Tissues', [], ) const factorIndices = schema.columns.filter(c => c.role === 'factor').map(c => c.index) expect(schema.groupBy).toEqual(factorIndices) }) }) describe('column roles review regressions', () => { const dose = (levels: string[]) => levels.flatMap((level, i) => [['Drug', level, `0${2 * i + 1}`], ['Drug', level, `0${2 * i + 2}`]]) it('should group by a bare-number dose when it sits before the injection number', () => { expect(buildClassSchema(dose(['1', '5', '10']), 'unknown', undefined, []).groupBy).toEqual([1]) }) it('should leave a counter out of the group key when it sits before the injection number', () => { expect(buildClassSchema(dose(['1', '2', '3']), 'unknown', undefined, []).groupBy).toEqual([]) }) it('should return factor when each sample has its own word with no digit', () => { expect(inferRole({ values: ['Ctrl', 'DrugA', 'DrugB', 'DrugC'], position: 0, totalPositions: 2, classSize: 4 })).toBe('factor') }) it('should return replicate when a zero-padded counter repeats at the end of a name', () => { const values = ['01', '02', '03', '04', '01', '02', '03', '04'] expect(inferRole({ values, position: 2, totalPositions: 3, classSize: 8 })).toBe('replicate') }) it('should return replicate when one counter of the run is missing', () => { const values = ['1', '2', '4', '1', '2', '4'] expect(inferRole({ values, position: 2, totalPositions: 3, classSize: 6 })).toBe('replicate') }) it('should return id when one value of a sample-number column has no digit', () => { const values = [...Array.from({ length: 19 }, (_, i) => String(i + 11)), 'pool'] expect(inferRole({ values, position: 1, totalPositions: 3, classSize: 20 })).toBe('id') }) })