import { describe, it, expect } from 'vitest' import { useAutoGroup, parseCSV } from '../../../composables/useAutoGroup' /** * Layer-order behaviour of the auto-group engine: `groupBy` is an ORDERED list * of hierarchy layers (first entry = the sample selector's major group), user * toggles append at the end, and `moveGroupBy` reorders layers explicitly. */ function structuredEngine() { const auto = useAutoGroup() auto.inputMode.value = 'csv' auto.csvData.value = parseCSV([ 'Sample,Tissue,Timepoint,Dose', 'S1,Kidney,5min,High', 'S2,Kidney,10min,Low', 'S3,Liver,5min,High', ].join('\n')) auto.parseInput() return auto } describe('useAutoGroup — groupBy layer order', () => { it('should append a re-enabled column at the end of the layer order when toggled back on', () => { const auto = structuredEngine() const key = auto.activeClassKey.value auto.toggleGroupBy(key, 0) auto.toggleGroupBy(key, 0) expect(auto.activeSchema.value?.groupBy).toEqual([1, 2, 0]) }) it('should compose group names following the toggle-defined layer order', () => { const auto = structuredEngine() const key = auto.activeClassKey.value auto.toggleGroupBy(key, 0) auto.toggleGroupBy(key, 0) expect(auto.groups.value.map(g => g.name).sort()).toEqual([ '10min / Low / Kidney', '5min / High / Kidney', '5min / High / Liver', ]) }) it('should move a layer toward the front when moveGroupBy direction is up', () => { const auto = structuredEngine() const key = auto.activeClassKey.value auto.moveGroupBy(key, 1, 'up') expect(auto.activeSchema.value?.groupBy).toEqual([1, 0, 2]) }) it('should compose group names following the moved layer order', () => { const auto = structuredEngine() const key = auto.activeClassKey.value auto.moveGroupBy(key, 1, 'up') expect(auto.groups.value.map(g => g.name).sort()).toEqual([ '10min / Kidney / Low', '5min / Kidney / High', '5min / Liver / High', ]) }) it('should ignore moveGroupBy up when the layer is already first', () => { const auto = structuredEngine() const key = auto.activeClassKey.value auto.moveGroupBy(key, 0, 'up') expect(auto.activeSchema.value?.groupBy).toEqual([0, 1, 2]) }) it('should ignore moveGroupBy down when the layer is already last', () => { const auto = structuredEngine() const key = auto.activeClassKey.value auto.moveGroupBy(key, 2, 'down') expect(auto.activeSchema.value?.groupBy).toEqual([0, 1, 2]) }) it('should ignore moveGroupBy for a column that is not part of groupBy', () => { const auto = structuredEngine() const key = auto.activeClassKey.value auto.toggleGroupBy(key, 1) // disable Timepoint auto.moveGroupBy(key, 1, 'up') expect(auto.activeSchema.value?.groupBy).toEqual([0, 2]) }) }) describe('useAutoGroup — field edits span every class exposing the field', () => { /** Two tissue classes sharing a Timepoint × Dose schema. */ function twoClassEngine() { const auto = useAutoGroup() const names: string[] = [] for (const tissue of ['Liver', 'Kidney']) for (const time of ['5min', '10min']) for (const dose of ['High', 'Low']) for (const rep of [1, 2, 3]) names.push(`${tissue}_${time}_${dose}_rep${rep}`) auto.rawText.value = names.join('\n') auto.parseInput() return auto } function threeFactorClassEngine() { const auto = useAutoGroup() const names: string[] = [] for (const tissue of ['Liver', 'Kidney']) for (const first of ['A', 'B']) for (const second of ['C', 'D']) for (const third of ['E', 'F']) names.push(`${tissue}_${first}_${second}_${third}_rep1`) auto.rawText.value = names.join('\n') auto.parseInput() return auto } it('should reorder the layer in every class when moving a field by name', () => { const auto = twoClassEngine() auto.moveGroupByField('Field 3', 'up') expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2, 1], [2, 1]]) }) it('should move a field from a non-active schema when the active schema has it disabled', () => { const auto = twoClassEngine() const activeKey = auto.activeClassKey.value auto.toggleGroupBy(activeKey, 2) auto.moveGroupByField('Field 3', 'up') expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([[1], [2, 1]]) }) it('should converge previously divergent class orders to the active schema order', () => { const auto = threeFactorClassEngine() const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)! auto.moveGroupBy(inactiveKey, 3, 'up') auto.moveGroupBy(inactiveKey, 3, 'up') auto.moveGroupByField('Field 4', 'up') expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([ [1, 3, 2], [1, 3, 2], ]) }) it('should keep class-only fields after shared fields in their existing order', () => { const auto = threeFactorClassEngine() const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)! const inactive = auto.schemas.value[inactiveKey] auto.schemas.value = { ...auto.schemas.value, [inactiveKey]: { ...inactive, columns: [ ...inactive.columns, { index: 99, name: 'Class-only', sourceIndices: [], uniqueValues: [], cardinality: 0, role: 'factor', }, ], groupBy: [3, 99, 1, 2], }, } auto.moveGroupByField('Field 4', 'up') expect(auto.schemas.value[inactiveKey].groupBy).toEqual([1, 3, 2, 99]) }) it('should compose consistently ordered names across classes after a field move', () => { const auto = twoClassEngine() auto.moveGroupByField('Field 3', 'up') expect(auto.groups.value.map(g => g.name).sort()).toEqual([ 'Kidney / High / 10min', 'Kidney / High / 5min', 'Kidney / Low / 10min', 'Kidney / Low / 5min', 'Liver / High / 10min', 'Liver / High / 5min', 'Liver / Low / 10min', 'Liver / Low / 5min', ]) }) it('should disable a field in every class when toggling it off by name', () => { const auto = twoClassEngine() auto.toggleGroupByField('Timepoint') expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2], [2]]) }) it('should disable a batch field when any class still has it enabled', () => { const auto = twoClassEngine() // Diverge the classes: disable Timepoint in the non-active class only. const inactiveKey = Object.keys(auto.schemas.value).find(k => k !== auto.activeClassKey.value)! auto.toggleGroupBy(inactiveKey, 1) // The batch card reads ON while either class uses Timepoint. auto.toggleGroupByField('Timepoint') expect(Object.values(auto.schemas.value).every(s => !s.groupBy.includes(1))).toBe(true) }) it('should append a re-enabled field as the last layer in every class', () => { const auto = twoClassEngine() auto.toggleGroupByField('Timepoint') auto.toggleGroupByField('Timepoint') expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[2, 1], [2, 1]]) }) it('should not enable a same-named constant column when re-enabling a batch field', () => { const auto = twoClassEngine() const activeKey = auto.activeClassKey.value const active = auto.schemas.value[activeKey] auto.schemas.value = { ...auto.schemas.value, [activeKey]: { ...active, columns: active.columns.map(column => column.index === 2 ? { ...column, role: 'constant' } : column), groupBy: active.groupBy.filter(index => index !== 2), }, } auto.toggleGroupByField('Field 3') auto.toggleGroupByField('Field 3') expect(Object.values(auto.schemas.value).map(schema => schema.groupBy)).toEqual([[1], [1, 2]]) }) it('should ignore a field name that no class exposes', () => { const auto = twoClassEngine() auto.moveGroupByField('Nonexistent', 'up') expect(Object.values(auto.schemas.value).map(s => s.groupBy)).toEqual([[1, 2], [1, 2]]) }) }) describe('useAutoGroup — class prefix only when disambiguation is needed', () => { it('should not prefix group names when a single experimental class emits groups', () => { const auto = useAutoGroup() auto.rawText.value = [ 'Liver_5min_High_rep1', 'Liver_5min_High_rep2', 'Liver_10min_Low_rep1', 'Liver_10min_Low_rep2', ].join('\n') auto.parseInput() expect(auto.groups.value.map(g => g.name).sort()).toEqual([ '10min / Low', '5min / High', ]) }) it('should prefix each class with its short distinguishing label when several experimental classes emit groups', () => { const auto = useAutoGroup() auto.rawText.value = [ 'Liver_5min_High_rep1', 'Liver_10min_Low_rep1', 'Kidney_5min_High_rep1', 'Kidney_10min_Low_rep1', ].join('\n') auto.parseInput() expect(auto.groups.value.map(g => g.name).sort()).toEqual([ 'Kidney / 10min / Low', 'Kidney / 5min / High', 'Liver / 10min / Low', 'Liver / 5min / High', ]) }) it('should keep overlay QC groups unprefixed and unchanged', () => { const auto = useAutoGroup() auto.rawText.value = [ 'Liver_5min_rep1', 'Liver_10min_rep1', 'IQC_001', 'IQC_002', ].join('\n') auto.parseInput() expect(auto.qcGroups.value.map(g => g.name)).toEqual(['IQC']) }) })