import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { flushPromises, mount } from '@vue/test-utils' import { createPinia } from 'pinia' import { describe, expect, it, vi } from 'vitest' import AutoGroupModal from '../../components/AutoGroupModal.vue' import SampleSelector from '../../components/SampleSelector.vue' import SmartGroupFieldRecipe from '../../components/SmartGroupFieldRecipe.vue' import type { AutoGroupResult, SampleGroup } from '../../types' // Wait for the FileReader-backed CSV read (a macrotask, not a microtask) plus // the ensuing reactive renders to settle. async function settle() { for (let i = 0; i < 3; i++) { await new Promise(resolve => setTimeout(resolve, 0)) await flushPromises() } } describe('SampleSelector', () => { it('uses shared list selection for select-all and individual sample toggles', async () => { const wrapper = mount(SampleSelector, { props: { samples: ['S1', 'A very long sample name that gets truncated'], modelValue: [], enableGrouping: false, enableSmartGroup: false, }, global: { stubs: { AutoGroupModal: true, }, }, }) await wrapper.find('.mint-sample-selector__select-all input').trigger('change') expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['S1', 'A very long sample name that gets truncated']]) await wrapper.setProps({ modelValue: ['S1'] }) await wrapper.findAll('.mint-sample-selector__flat-item input')[0].trigger('change') expect(wrapper.emitted('update:modelValue')?.[1]).toEqual([[]]) await wrapper.findAll('.mint-sample-selector__flat-item input')[1].trigger('change') expect(wrapper.emitted('update:modelValue')?.[2]).toEqual([['S1', 'A very long sample name that gets truncated']]) expect(wrapper.findAll('.mint-sample-selector__flat-item')[1].attributes('title')).toBe( 'A very long sample name that gets truncated', ) }) it('uses shared expansion state for grouped sample sections', async () => { const wrapper = mount(SampleSelector, { props: { samples: ['S1', 'S2'], modelValue: [], groups: [ { name: 'Control/A', color: '#3B82F6', samples: ['S1'] }, { name: 'Control/B', color: '#06B6D4', samples: ['S2'] }, ], enableSmartGroup: false, }, global: { stubs: { AutoGroupModal: true, }, }, }) expect(wrapper.find('.mint-sample-selector__sample-name').exists()).toBe(false) await wrapper.find('.mint-sample-selector__expand-btn').trigger('click') expect(wrapper.findAll('.mint-sample-selector__sample-name').map(item => item.text())).toEqual(['S1', 'S2']) await wrapper.findAll('.mint-sample-selector__expand-btn')[1].trigger('click') expect(wrapper.find('.mint-sample-selector__sample-name').exists()).toBe(false) }) it('can derive samples directly from experiment design data', () => { const wrapper = mount(SampleSelector, { props: { modelValue: [], designData: { samples: [ { sample_name: 'S1', sample_type: 'sample' }, { sample_name: 'Blank', sample_type: 'blank' }, { sample_name: 'S2', sample_type: 'sample' }, ], }, enableGrouping: false, enableSmartGroup: false, }, global: { stubs: { AutoGroupModal: true, }, }, }) expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('0 / 2') expect(wrapper.findAll('.mint-sample-selector__flat-name').map(item => item.text())).toEqual(['S1', 'S2']) }) it('merges smart and manual grouping into one launcher', async () => { const wrapper = mount(SampleSelector, { props: { samples: ['S1', 'S2'], modelValue: [], groups: [], }, global: { stubs: { AutoGroupModal: true, }, }, }) const labels = wrapper.findAll('.mint-sample-selector__actions-row .mint-sample-selector__action-label') expect(labels.map(label => label.text())).toEqual(['Grouping']) expect(wrapper.findAll('.mint-sample-selector__actions-row > .mint-sample-selector__action-btn')).toHaveLength(2) await wrapper.findAllComponents({ name: 'BaseButton' })[0].trigger('click') const modal = wrapper.findComponent({ name: 'AutoGroupModal' }) expect(modal.props('initialMode')).toBe('auto') }) it('opens the grouping modal in manual mode', async () => { const wrapper = mount(SampleSelector, { props: { samples: ['S1', 'S2'], modelValue: [], groups: [], enableSmartGroup: false, }, global: { stubs: { AutoGroupModal: true, }, }, }) const labels = wrapper.findAll('.mint-sample-selector__actions-row .mint-sample-selector__action-label') expect(labels.map(label => label.text())).toEqual(['Grouping']) await wrapper.findAllComponents({ name: 'BaseButton' })[0].trigger('click') const modal = wrapper.findComponent({ name: 'AutoGroupModal' }) expect(modal.props('initialMode')).toBe('manual') expect(modal.props('groups')).toEqual([]) }) }) describe('AutoGroupModal manual workflow', () => { it('assigns selected samples to a manual group and subgroup', async () => { const wrapper = mount(AutoGroupModal, { props: { modelValue: true, initialMode: 'manual', samples: ['S1', 'S2'], groups: [], }, global: { plugins: [createPinia()], stubs: { Teleport: true, }, }, }) await wrapper.findAll('.mg2-row')[0].trigger('click') const fields = wrapper.findAll('.mg2-bar .mg2-combo input') await fields[0].setValue('Treatment') await fields[1].setValue('Baseline') await wrapper.find('.mg2-bar .sg-btn--primary').trigger('click') await wrapper.find('.sg-foot .mint-button--primary').trigger('click') const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult expect(result.groups).toHaveLength(1) expect(result.groups[0]).toMatchObject({ name: 'Treatment/Baseline', samples: ['S1'], // A newly-created manual cohort must carry a concrete hex colour (the // first SAMPLE_GROUP_COLOR_OPTIONS swatch), not a `var(--grp-*)` string // that breaks SampleSelector's hex8 tint concatenation. color: '#3B82F6', }) }) it('moves manual selections out of existing groups before assigning them', async () => { const wrapper = mount(AutoGroupModal, { props: { modelValue: true, initialMode: 'manual', samples: ['S1', 'S2'], groups: [ { name: 'Control', color: '#3B82F6', samples: ['S1'] }, { name: 'Treatment', color: '#10B981', samples: [] }, ], }, global: { plugins: [createPinia()], stubs: { Teleport: true, }, }, }) await wrapper.findAll('.mg2-row')[0].trigger('click') await wrapper.findAll('.mg2-bar .mg2-combo input')[0].setValue('Treatment') await wrapper.find('.mg2-bar .sg-btn--primary').trigger('click') await wrapper.find('.sg-foot .mint-button--primary').trigger('click') const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult expect(result.groups).toEqual([ { name: 'Control', color: '#3B82F6', samples: [] }, { name: 'Treatment', color: '#10B981', samples: ['S1'] }, ]) }) it('preserves an existing cohort color when the target is typed manually', async () => { const wrapper = mount(AutoGroupModal, { props: { modelValue: true, initialMode: 'manual', samples: ['S1', 'S2'], groups: [ { name: 'Control', color: '#10B981', samples: ['S1'] }, ], }, global: { plugins: [createPinia()], stubs: { Teleport: true, }, }, }) await wrapper.findAll('.mg2-row')[1].trigger('click') await wrapper.findAll('.mg2-bar .mg2-combo input')[0].setValue('Control') await wrapper.find('.mg2-bar .sg-btn--primary').trigger('click') await wrapper.find('.sg-foot .mint-button--primary').trigger('click') const result = wrapper.emitted('apply')?.at(-1)?.[0] as AutoGroupResult expect(result.groups).toEqual([ { name: 'Control', color: '#10B981', samples: ['S1', 'S2'] }, ]) }) }) describe('AutoGroupModal multi-schema auto workflow', () => { it('should keep apply enabled when another sample schema still has a grouping field', async () => { const samples = [ ...['5916', '5918', '5919', '5920', '5921'].map((id, index) => `${id}_batch_project_matrix_subject_${String(index + 1).padStart(3, '0')}`, ), ...Array.from({ length: 5 }, (_, index) => `sample_1x_${String(index + 1).padStart(3, '0')}`, ), ...Array.from({ length: 5 }, (_, index) => `sample_2x_${String(index + 6).padStart(3, '0')}`, ), 'IQC_Pool_001', 'IQC_Pool_002', 'IQC_Pool_003', ] const wrapper = mount(AutoGroupModal, { props: { modelValue: true, samples, }, global: { plugins: [createPinia()], stubs: { Teleport: true, }, }, }) // Switch the sample-ID field of the first class on, then the only field of // the second class off: one class still has a grouping field. await wrapper.find('[data-test="field-off"] [role="switch"]').trigger('click') const otherField = wrapper.findAll('[data-test="field-on"]').find(row => !row.text().includes('Sample ID')) await otherField!.find('[role="switch"]').trigger('click') expect(wrapper.find('.sg-foot .mint-button--primary').attributes('disabled')).toBeUndefined() }) it('should scroll the token field list when many fields exceed the modal height', () => { const styles = readFileSync( resolve(process.cwd(), 'src/styles/components/smart-group.css'), 'utf8', ) const mainPanelStyles = styles.match(/\.sg-body__main\s*\{([^}]*)\}/)?.[1] expect(mainPanelStyles).toContain('overflow-y: auto') }) it('should let the token field panel shrink within the fixed modal body', () => { const styles = readFileSync( resolve(process.cwd(), 'src/styles/components/smart-group.css'), 'utf8', ) const mainPanelStyles = styles.match(/\.sg-body__main\s*\{([^}]*)\}/)?.[1] expect(mainPanelStyles).toContain('min-height: 0') }) it('should scroll the group preview when generated groups exceed the modal height', () => { const styles = readFileSync( resolve(process.cwd(), 'src/styles/components/smart-group.css'), 'utf8', ) const sidePanelStyles = styles.match(/\.sg-body__side\s*\{([^}]*)\}/)?.[1] expect(sidePanelStyles).toMatch(/min-height:\s*0;[\s\S]*overflow-y:\s*auto;/) }) }) describe('SampleSelector — QC overlay regression', () => { it('keeps QC samples in the emitted groups via the concat compat rule', async () => { const exp: SampleGroup[] = [ { name: 'Kidney', color: '#3B82F6', samples: ['s1', 's2'] }, ] const qc: SampleGroup[] = [ { name: 'IQC', color: '#6B7280', samples: ['iqc1'] }, ] const result: AutoGroupResult = { groups: [...exp, ...qc], experimentalGroups: exp, qcGroups: qc, metadata: [], excludedSamples: [], } const wrapper = mount(SampleSelector, { props: { samples: ['s1', 's2', 'iqc1'], modelValue: [] }, global: { stubs: { AutoGroupModal: true, }, }, }) ;(wrapper.vm as unknown as { handleSmartGroupApply: (r: AutoGroupResult) => void }) .handleSmartGroupApply(result) const emitted = wrapper.emitted('update:groups')!.at(-1)![0] as SampleGroup[] const allSamples = emitted.flatMap(g => g.samples) expect(allSamples).toContain('iqc1') }) }) describe('AutoGroupModal CSV upload', () => { it('loads an uploaded CSV into the engine so samples reach both auto and manual modes', async () => { const wrapper = mount(AutoGroupModal, { props: { modelValue: true, samples: [], groups: [] }, global: { plugins: [createPinia()], stubs: { Teleport: true }, }, }) const csv = 'sample_name,Condition\nS1,Control\nS2,Control\nS3,Treatment\n' const file = new File([csv], 'samples.csv', { type: 'text/csv' }) wrapper.findComponent(SmartGroupFieldRecipe).vm.$emit('csv-file', file) await settle() // Auto mode: the CSV populated the engine's field cards + grouped all 3 samples. const recipe = wrapper.findComponent(SmartGroupFieldRecipe) expect((recipe.props('fields') as unknown[]).length).toBeGreaterThan(0) const rail = recipe.props('groups') as Array<{ count: number }> expect(rail.reduce((acc, g) => acc + g.count, 0)).toBe(3) // Manual mode: the CSV sample names populate the manual list (sourced from // the engine, not props.samples — which is empty here). recipe.vm.$emit('manual') await flushPromises() const names = wrapper.findAll('.mg2-row__name').map(n => n.text()) expect(names).toEqual(['S1', 'S2', 'S3']) }) it('surfaces an error (without throwing) for an empty/header-only CSV, then loads a valid one', async () => { const wrapper = mount(AutoGroupModal, { props: { modelValue: true, samples: [], groups: [] }, global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) const recipe = wrapper.findComponent(SmartGroupFieldRecipe) // Header-only CSV → parseCSV throws; onCsvFile must catch + surface the error. recipe.vm.$emit('csv-file', new File(['sample_name\n'], 'bad.csv', { type: 'text/csv' })) await settle() expect(wrapper.find('.sg-host__error').exists()).toBe(true) // Engine left un-mutated: no groups loaded from the bad file. expect((recipe.props('groups') as unknown[]).length).toBe(0) // A subsequent valid CSV clears the error and loads. recipe.vm.$emit( 'csv-file', new File(['sample_name,Condition\nS1,Control\nS2,Treatment\n'], 'good.csv', { type: 'text/csv' }), ) await settle() expect(wrapper.find('.sg-host__error').exists()).toBe(false) const rail = recipe.props('groups') as Array<{ count: number }> expect(rail.reduce((acc, g) => acc + g.count, 0)).toBe(2) }) }) describe('SmartGroupFieldRecipe CSV menu', () => { const menuOf = (wrapper: ReturnType) => wrapper.findComponent({ name: 'ActionMenu' }) it('should emit download-template when the user picks that menu item', () => { const wrapper = mount(SmartGroupFieldRecipe) menuOf(wrapper).vm.$emit('select', { id: 'template', label: 'Download template' }) expect(wrapper.emitted('download-template')).toHaveLength(1) }) it('should emit download-prefilled when the user picks that menu item', () => { const wrapper = mount(SmartGroupFieldRecipe) menuOf(wrapper).vm.$emit('select', { id: 'prefilled', label: 'Download pre-filled' }) expect(wrapper.emitted('download-prefilled')).toHaveLength(1) }) it('should disable the two download items when canDownloadTemplate is false', () => { const wrapper = mount(SmartGroupFieldRecipe, { props: { canDownloadTemplate: false } }) const items = menuOf(wrapper).props('items') as Array<{ id: string; disabled?: boolean }> expect(items.filter(item => item.disabled).map(item => item.id)).toEqual(['template', 'prefilled']) }) }) describe('AutoGroupModal template download wiring', () => { it('maps the menu actions to engine downloadTemplate (blank vs prefilled)', async () => { const blobs: Blob[] = [] const origCreate = URL.createObjectURL const origRevoke = URL.revokeObjectURL URL.createObjectURL = vi.fn((b: Blob) => { blobs.push(b) return 'blob:mock' }) as typeof URL.createObjectURL URL.revokeObjectURL = vi.fn() as typeof URL.revokeObjectURL const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) try { const wrapper = mount(AutoGroupModal, { props: { modelValue: true, samples: ['Ctrl_R1', 'Ctrl_R2', 'Drug_R1', 'Drug_R2'], groups: [] }, global: { plugins: [createPinia()], stubs: { Teleport: true } }, }) const recipe = wrapper.findComponent(SmartGroupFieldRecipe) recipe.vm.$emit('download-template') await flushPromises() recipe.vm.$emit('download-prefilled') await flushPromises() expect(blobs).toHaveLength(2) const blankText = await blobs[0].text() const prefilledText = await blobs[1].text() // download-template → blank mode: the canonical generic DEFAULT_BLANK_HEADERS. // download-prefilled → prefilled mode: a different, detected-column header. expect(blankText.split('\n')[0]).toBe('sample_name,group,class,replicate,notes') expect(prefilledText.split('\n')[0]).not.toBe(blankText.split('\n')[0]) expect(blankText).not.toBe(prefilledText) } finally { URL.createObjectURL = origCreate URL.revokeObjectURL = origRevoke clickSpy.mockRestore() } }) }) describe('SampleSelector checkboxes (7c)', () => { const grouped = { samples: ['S1', 'S2', 'S3'], modelValue: ['S1'], groups: [{ name: 'Control', color: '#3B82F6', samples: ['S1', 'S2'] }], enableSmartGroup: false, } const stubs = { global: { stubs: { AutoGroupModal: true } } } it('should show the mono selected / total count when some samples are selected', () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) expect(wrapper.find('.mint-sample-selector__select-all-count').text()).toBe('1 / 3') }) it('should mark the select-all checkbox mixed when some samples are selected', () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) expect(wrapper.find('.mint-sample-selector__select-all input').attributes('aria-checked')).toBe('mixed') }) it('should render the select-all row as a header div when samples exist', () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) expect(wrapper.find('.mint-sample-selector__select-all').element.tagName).toBe('DIV') }) it('should mark the select-all row grouped when groups are shown', () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) expect(wrapper.find('.mint-sample-selector__select-all').classes()).toContain('mint-sample-selector__select-all--grouped') }) it('should leave the select-all row ungrouped in the flat view', () => { const wrapper = mount(SampleSelector, { props: { ...grouped, groups: [] }, ...stubs }) expect(wrapper.find('.mint-sample-selector__select-all').classes()).not.toContain('mint-sample-selector__select-all--grouped') }) // happy-dom has no layout, so the header's columns are derived from the group tree's rules. function rems(selector: string, property: string): number[] { const styles = readFileSync(resolve(process.cwd(), 'src/styles/components/sample-selector.css'), 'utf8') const rule = styles.match(new RegExp(`${selector.replace(/\./g, '\\.')}\\s*\\{([^}]*)\\}`))![1] return rule.match(new RegExp(`(?:^|[;\\s])${property}:\\s*([^;]+)`))![1].trim().split(/\s+/).map(parseFloat) } it('should put the grouped select-all checkbox over the group checkboxes', () => { const header = '.mint-sample-selector__major-header' const checkboxOffset = rems(header, 'padding')[1] + rems('.mint-sample-selector__chevron', 'width')[0] + rems(header, 'gap')[0] expect(rems('.mint-sample-selector__select-all--grouped', 'padding-left')[0]).toBeCloseTo(checkboxOffset) }) it('should put the grouped select-all count over the group counts', () => { const header = '.mint-sample-selector__major-header' const deleteButton = rems('.mint-sample-selector__delete-btn svg', 'width')[0] + 2 * rems('.mint-sample-selector__delete-btn', 'padding')[0] const countOffset = rems('.mint-sample-selector__tree', 'padding-right')[0] + rems(header, 'padding')[1] + deleteButton + rems(header, 'gap')[0] expect(rems('.mint-sample-selector__select-all--grouped', 'padding-right')[0]).toBeCloseTo(countOffset) }) it('should mark a group checkbox mixed when part of its samples are selected', () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) expect(wrapper.find('.mint-sample-selector__major-header input').attributes('aria-checked')).toBe('mixed') }) it('should render group checkboxes as BaseCheckbox sm when grouped', () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) expect(wrapper.find('.mint-sample-selector__major-header .mint-checkbox__box--sm').exists()).toBe(true) }) it('should toggle the whole group when its checkbox changes', async () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) await wrapper.find('.mint-sample-selector__major-header input').setValue(true) expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['S1', 'S2']]) }) it('should not expand the group when its checkbox is clicked', async () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) await wrapper.find('.mint-sample-selector__major-header .mint-checkbox').trigger('click') expect(wrapper.find('.mint-sample-selector__major-header').attributes('aria-expanded')).toBe('false') }) it('should not expand the group when Space is pressed on its checkbox', async () => { const wrapper = mount(SampleSelector, { props: grouped, ...stubs }) await wrapper.find('.mint-sample-selector__major-header input').trigger('keydown', { key: ' ' }) expect(wrapper.find('.mint-sample-selector__major-header').attributes('aria-expanded')).toBe('false') }) it('should render flat sample checkboxes as BaseCheckbox sm when ungrouped', () => { const wrapper = mount(SampleSelector, { props: { samples: ['S1'], modelValue: [], enableGrouping: false }, ...stubs, }) expect(wrapper.find('.mint-sample-selector__flat-item .mint-checkbox__box--sm').exists()).toBe(true) }) })