import { describe, expect, it } from 'vitest' import { candidateColumns, engineFieldsToCards, engineGroupsToRail, engineSchemasToCards, engineToQcRoute, manualSamplesToResult, qcRouteToEngine, roleLabel, seedFromGroups, } from '../../components/AutoGroupModal.adapter' import type { ClassSchema, ColumnInfo } from '../../types/auto-group' import type { SampleGroup } from '../../types' import type { ManualSample } from '../../components/SmartGroupManual.cohorts' function col(partial: Partial & { index: number }): ColumnInfo { return { name: `c${partial.index}`, sourceIndices: [partial.index], uniqueValues: [], cardinality: 0, ...partial, } } describe('roleLabel', () => { it('should map known roles to their display label', () => { expect(roleLabel('factor')).toBe('Factor') expect(roleLabel('replicate')).toBe('Replicate') expect(roleLabel('run-order')).toBe('Run') }) it('should default to Factor when the role is undefined', () => { expect(roleLabel(undefined)).toBe('Factor') }) }) describe('candidateColumns', () => { it('should include factors and replicates and drop constants/ignore', () => { const columns = [ col({ index: 0, role: 'factor' }), col({ index: 1, role: 'constant' }), col({ index: 2, role: 'replicate' }), col({ index: 3, role: 'ignore' }), ] expect(candidateColumns(columns, []).map(c => c.index)).toEqual([0, 2]) }) it('should also include a non-factor column already enabled in groupBy', () => { const columns = [ col({ index: 0, role: 'factor' }), col({ index: 1, role: 'numeric' }), ] expect(candidateColumns(columns, [1]).map(c => c.index)).toEqual([0, 1]) }) }) describe('engineFieldsToCards', () => { it('should build cards with role label, joined values, rep flag, and aligned enabled state', () => { const columns = [ col({ index: 0, name: 'Condition', role: 'factor', uniqueValues: ['Control', 'Treatment'] }), col({ index: 1, name: 'Replicate', role: 'replicate', uniqueValues: ['Rep1', 'Rep2'] }), ] const { fields, enabled } = engineFieldsToCards(columns, [0]) expect(fields).toEqual([ { key: 'Condition', role: 'Factor', vals: 'Control, Treatment', rep: false }, { key: 'Replicate', role: 'Replicate', vals: 'Rep1, Rep2', rep: true }, ]) expect(enabled).toEqual([true, false]) }) it('should prefer displayName over name when present', () => { const columns = [col({ index: 0, name: 'c0', displayName: 'Dose', role: 'factor', uniqueValues: ['Low'] })] expect(engineFieldsToCards(columns, []).fields[0].key).toBe('Dose') }) it('should map each card to its 1-based hierarchy layer from the groupBy order', () => { const columns = [ col({ index: 0, name: 'Tissue', role: 'factor' }), col({ index: 1, name: 'Timepoint', role: 'factor' }), col({ index: 2, name: 'Rep', role: 'replicate' }), ] // Layer 1 = Timepoint (column 1), layer 2 = Tissue (column 0); Rep is off. expect(engineFieldsToCards(columns, [1, 0]).layers).toEqual([2, 1, null]) }) }) describe('engineSchemasToCards', () => { it('should expose enabled fields from another schema when the active schema has none enabled', () => { const schemas: Record = { active: { classKind: 'unknown', columns: [col({ index: 0, name: 'Token 1', role: 'factor', uniqueValues: ['5916', '5918'] })], groupBy: [], }, other: { classKind: 'unknown', columns: [ col({ index: 0, name: 'Token 1', role: 'constant', uniqueValues: ['sample'] }), col({ index: 1, name: 'Token 2', role: 'factor', uniqueValues: ['1x', '2x'] }), ], groupBy: [1], }, } expect(engineSchemasToCards(schemas, 'active')).toEqual({ fields: [ { key: 'Token 1', role: 'Factor', vals: '5916, 5918', rep: false }, { key: 'Token 2', role: 'Factor', vals: '1x, 2x', rep: false }, ], enabled: [false, true], layers: [null, 1], }) }) }) describe('engineGroupsToRail', () => { it('should map groups to {name, count, color} using sample length', () => { const groups: SampleGroup[] = [{ name: 'Control', color: '#111', samples: ['a', 'b'] }] expect(engineGroupsToRail(groups)).toEqual([{ name: 'Control', count: 2, color: '#111' }]) }) }) describe('QC route mapping', () => { it('should round-trip between Field Recipe labels and engine routing', () => { expect(qcRouteToEngine('Overlay only')).toBe('overlay') expect(qcRouteToEngine('Exclude')).toBe('exclude') expect(qcRouteToEngine('Mix into groups')).toBe('mix') expect(engineToQcRoute('overlay')).toBe('Overlay only') expect(engineToQcRoute('exclude')).toBe('Exclude') expect(engineToQcRoute('mix')).toBe('Mix into groups') }) }) describe('seedFromGroups', () => { it('should split a hierarchical group name into group/sub/sub2 per sample', () => { const groups: SampleGroup[] = [ { name: 'Responder/Day 7/Batch 1', color: '#0EA5A4', samples: ['s1'] }, { name: 'Control', color: '#3B82F6', samples: ['s2', 's3'] }, ] expect(seedFromGroups(groups)).toEqual({ s1: { group: 'Responder', sub: 'Day 7', sub2: 'Batch 1', color: '#0EA5A4' }, s2: { group: 'Control', sub: undefined, sub2: undefined, color: '#3B82F6' }, s3: { group: 'Control', sub: undefined, sub2: undefined, color: '#3B82F6' }, }) }) }) describe('manualSamplesToResult', () => { function sample(name: string, group: string | null, sub: string | null = null, color = 'var(--grp-1)'): ManualSample { return { name, group, sub, sub2: null, color: group ? color : null } } it('should build a hierarchical group name and wrap as an AutoGroupResult', () => { const samples: ManualSample[] = [ { name: 'S1', group: 'Treatment', sub: 'Baseline', sub2: null, color: 'var(--grp-1)' }, sample('S2', null), ] const result = manualSamplesToResult(samples) expect(result.groups).toHaveLength(1) expect(result.groups[0]).toMatchObject({ name: 'Treatment/Baseline', samples: ['S1'] }) expect(result.experimentalGroups).toEqual(result.groups) expect(result.qcGroups).toEqual([]) }) it('should round-trip a 4-segment group name through seedFromGroups without losing segments', () => { const groups: SampleGroup[] = [{ name: 'a/b/c/d', color: '#123456', samples: ['s1'] }] const seeded = seedFromGroups(groups).s1 const samples: ManualSample[] = [ { name: 's1', group: seeded.group ?? null, sub: seeded.sub ?? null, sub2: seeded.sub2 ?? null, color: seeded.color ?? null, }, ] // No baseGroups, so the name is reconstructed purely from the seeded levels. const result = manualSamplesToResult(samples, []) expect(result.groups).toHaveLength(1) expect(result.groups[0]).toMatchObject({ name: 'a/b/c/d', samples: ['s1'] }) }) it('should preserve base group order and colour, leaving emptied groups in place', () => { const samples: ManualSample[] = [sample('S1', 'Treatment'), sample('S2', null)] const base: SampleGroup[] = [ { name: 'Control', color: '#3B82F6', samples: ['S1'] }, { name: 'Treatment', color: '#10B981', samples: [] }, ] expect(manualSamplesToResult(samples, base).groups).toEqual([ { name: 'Control', color: '#3B82F6', samples: [] }, { name: 'Treatment', color: '#10B981', samples: ['S1'] }, ]) }) it('should keep a base group colour when samples are added to it', () => { const samples: ManualSample[] = [sample('S1', 'Control', null, '#10B981'), sample('S2', 'Control', null, '#10B981')] const base: SampleGroup[] = [{ name: 'Control', color: '#10B981', samples: ['S1'] }] expect(manualSamplesToResult(samples, base).groups).toEqual([ { name: 'Control', color: '#10B981', samples: ['S1', 'S2'] }, ]) }) })