import { mount } from '@vue/test-utils' import { describe, expect, it, vi } from 'vitest' import { h, nextTick } from 'vue' import PlateEditor from '../../components/PlateEditor.vue' import WellPlate from '../../components/WellPlate.vue' import ConfirmDialog from '../../components/ConfirmDialog.vue' import ActionMenu from '../../components/ActionMenu.vue' import WellEditPopupInternal from '../../components/internal/WellEditPopupInternal.vue' import type { Rack, WellEditData } from '../../types' function makeRacks(): Rack[] { return [ { id: 'rack-1', name: 'Rack 1', format: 54, slot: 'R', injectionVolume: 5, wells: { A1: { sampleType: 'sample', state: 'filled', metadata: { label: 'S001', injectionCount: 1 } }, }, }, { id: 'rack-2', name: 'Rack 2', format: 96, slot: 'G', injectionVolume: 5, wells: {} }, ] } const editData: WellEditData = { wellId: 'A2', label: 'Renamed', sampleType: 'qc', injectionVolume: 5, injectionCount: 2, customMethod: '', } function mountEditor(props: Record = {}, options: Record = {}) { return mount(PlateEditor, { props: { modelValue: makeRacks(), ...props }, global: { stubs: { teleport: true } }, ...options, }) } /** Mounts at a wide width, so the rail shows (happy-dom measures every box as 0 wide). */ async function mountWide(props: Record = {}, options: Record = {}) { const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 960 } as DOMRect) try { const wrapper = mountEditor(props, options) await nextTick() return wrapper } finally { spy.mockRestore() } } function plate(wrapper: ReturnType) { return wrapper.findComponent(WellPlate) } function dropEvent(data: Record): DragEvent { return { preventDefault: vi.fn(), dataTransfer: { getData: (type: string) => data[type] ?? '' }, } as unknown as DragEvent } function cell(wrapper: ReturnType, wellId: string) { return wrapper.get(`[role="gridcell"][title^="${wellId}"]`) } function tabs(wrapper: ReturnType) { return wrapper.findAll('[role="tab"]') } describe('PlateEditor — controlled data', () => { it('should leave modelValue untouched when a well is edited', async () => { const racks = makeRacks() const snapshot = structuredClone(racks) const wrapper = mountEditor({ modelValue: racks, sampleDrop: true, fillSeries: true, clear: 'undo' }) plate(wrapper).vm.$emit('well-edit', 'A2', editData) plate(wrapper).vm.$emit('well-move', 'A1', 'B1') await wrapper.get('.mint-plate-editor__action--danger').trigger('click') await wrapper.get('[data-action="fill-series"]').trigger('click') expect(racks).toEqual(snapshot) }) it('should emit well-edit with the active rack id', () => { const wrapper = mountEditor() plate(wrapper).vm.$emit('well-edit', 'A2', editData) expect(wrapper.emitted('well-edit')?.[0]).toEqual(['rack-1', 'A2', editData]) }) it('should emit well-clear when a well is saved without a name', () => { const wrapper = mountEditor() plate(wrapper).vm.$emit('well-edit', 'A1', { ...editData, wellId: 'A1', label: '', sampleType: 'sample' }) expect(wrapper.emitted('well-clear')?.[0]).toEqual(['rack-1', 'A1']) }) it('should keep an unnamed blank well when it is saved', () => { const wrapper = mountEditor() plate(wrapper).vm.$emit('well-edit', 'A2', { ...editData, label: '', sampleType: 'blank' }) expect(wrapper.emitted('well-clear')).toBeUndefined() }) it('should emit well-click in readonly mode', () => { const wrapper = mountEditor({ readonly: true }) plate(wrapper).vm.$emit('well-click', 'A1', new MouseEvent('click')) expect(wrapper.emitted('well-click')?.[0]).toEqual(['rack-1', 'A1']) }) it('should hide the toolbar in readonly mode', () => { const wrapper = mountEditor({ readonly: true, slots: true, fillSeries: true }) expect(wrapper.find('.mint-plate-editor__toolbar').exists()).toBe(false) }) }) describe('PlateEditor — plate tabs', () => { it('should emit update:activeRackId when a tab is clicked', async () => { const wrapper = mountEditor() await tabs(wrapper)[1].trigger('click') expect(wrapper.emitted('update:activeRackId')?.[0]).toEqual(['rack-2']) }) it('should show the second rack after its tab is clicked without a bound activeRackId', async () => { const wrapper = mountEditor() await tabs(wrapper)[1].trigger('click') expect(plate(wrapper).props('format')).toBe(96) }) it('should keep showing the bound rack when the parent ignores a tab change', async () => { const wrapper = mountEditor({ activeRackId: 'rack-1', 'onUpdate:activeRackId': () => {} }) await tabs(wrapper)[1].trigger('click') expect(plate(wrapper).props('format')).toBe(54) }) it('should show the rack named by a new activeRackId', async () => { const wrapper = mountEditor({ activeRackId: 'rack-1' }) await wrapper.setProps({ activeRackId: 'rack-2' }) expect(plate(wrapper).props('format')).toBe(96) }) it('should render the well count as a bare mono count', () => { const wrapper = mountEditor() expect(tabs(wrapper)[0].get('.mint-plate-editor__tab-count').text()).toBe('1') }) it('should underline the active tab together with its close button', () => { const wrapper = mountEditor() expect(wrapper.find('.mint-plate-editor__tab-item--active .mint-plate-editor__tab-close').exists()).toBe(true) }) it('should offer a close button on the active tab only', () => { const wrapper = mountEditor() expect(wrapper.findAll('.mint-plate-editor__tab-close')).toHaveLength(1) }) it('should emit rack-remove from the close button', async () => { const wrapper = mountEditor() await wrapper.get('.mint-plate-editor__tab-close').trigger('click') expect(wrapper.emitted('rack-remove')?.[0]).toEqual(['rack-1']) }) it('should hide the close button at minRacks', () => { const wrapper = mountEditor({ minRacks: 2 }) expect(wrapper.find('.mint-plate-editor__tab-close').exists()).toBe(false) }) it('should emit rack-add from the add button', async () => { const wrapper = mountEditor() await wrapper.get('.mint-plate-editor__add').trigger('click') expect(wrapper.emitted('rack-add')).toHaveLength(1) }) it('should hide the add button at maxRacks', () => { const wrapper = mountEditor({ maxRacks: 2 }) expect(wrapper.find('.mint-plate-editor__add').exists()).toBe(false) }) it('should emit rack-reorder on Alt+ArrowRight', async () => { const wrapper = mountEditor() await tabs(wrapper)[0].trigger('keydown', { key: 'ArrowRight', altKey: true }) expect(wrapper.emitted('rack-reorder')?.[0]).toEqual([['rack-2', 'rack-1']]) }) it('should not reorder when reorder is off', async () => { const wrapper = mountEditor({ reorder: false }) await tabs(wrapper)[0].trigger('keydown', { key: 'ArrowRight', altKey: true }) expect(wrapper.emitted('rack-reorder')).toBeUndefined() }) it('should render #tab-extra content', () => { const wrapper = mountEditor({}, { slots: { 'tab-extra': () => h('span', { class: 'extra' }, 'x') } }) expect(wrapper.find('.extra').exists()).toBe(true) }) }) describe('PlateEditor — toolbar', () => { it('should name the format radio group', () => { const wrapper = mountEditor() expect(wrapper.get('.mint-plate-editor__toolbar [role="radiogroup"]').attributes('aria-label')).toBe('Format') }) it('should emit update:format from the format control', async () => { const wrapper = mountEditor() await wrapper.findAll('.mint-plate-editor__toolbar [role="radio"]')[1].trigger('click') expect(wrapper.emitted('update:format')?.[0]).toEqual(['rack-1', 96]) }) it('should hide the format control when only one format is allowed', () => { const wrapper = mountEditor({ formats: [54] }) expect(wrapper.find('.mint-plate-editor__toolbar [role="radio"]').exists()).toBe(false) }) it('should emit update:slot from a slot button', async () => { const wrapper = mountEditor({ slots: true }) await wrapper.findAll('.mint-plate-editor__slot')[2].trigger('click') expect(wrapper.emitted('update:slot')?.[0]).toEqual(['rack-1', 'B']) }) it('should limit slot buttons to the listed slots', () => { const wrapper = mountEditor({ slots: ['R', 'G'] }) expect(wrapper.findAll('.mint-plate-editor__slot')).toHaveLength(2) }) it('should mark the active slot as pressed', () => { const wrapper = mountEditor({ slots: true }) expect(wrapper.get('.mint-plate-editor__slot--active').text()).toBe('R') }) it('should emit fill-series from its button', async () => { const wrapper = mountEditor({ fillSeries: true }) await wrapper.get('[data-action="fill-series"]').trigger('click') expect(wrapper.emitted('fill-series')?.[0]).toEqual(['rack-1']) }) it('should disable fill-series when the plate is full', () => { const racks = makeRacks() racks[0].format = 6 racks[0].wells = Object.fromEntries(['A1', 'A2', 'A3', 'B1', 'B2', 'B3'].map(id => [id, { sampleType: 'sample' }])) const wrapper = mountEditor({ modelValue: racks, fillSeries: true }) expect(wrapper.get('[data-action="fill-series"]').attributes('disabled')).toBeDefined() }) it('should ask for confirmation before clearing when clear is confirm', async () => { const wrapper = mountEditor() await wrapper.get('.mint-plate-editor__action--danger').trigger('click') expect(wrapper.emitted('clear')).toBeUndefined() }) it('should emit clear once the confirmation is accepted', async () => { const wrapper = mountEditor() await wrapper.get('.mint-plate-editor__action--danger').trigger('click') wrapper.findComponent(ConfirmDialog).vm.$emit('confirm') expect(wrapper.emitted('clear')?.[0]).toEqual(['rack-1']) }) it('should emit clear at once when clear is undo', async () => { const wrapper = mountEditor({ clear: 'undo' }) await wrapper.get('.mint-plate-editor__action--danger').trigger('click') expect(wrapper.emitted('clear')?.[0]).toEqual(['rack-1']) }) it('should emit undo from the undo button', async () => { const wrapper = mountEditor({ clear: 'undo', canUndo: true }) await wrapper.get('[aria-label="Undo"]').trigger('click') expect(wrapper.emitted('undo')).toHaveLength(1) }) it('should emit undo on Ctrl+Z inside the editor', async () => { const wrapper = mountEditor({ clear: 'undo', canUndo: true }) await wrapper.get('.mint-plate-editor').trigger('keydown', { key: 'z', ctrlKey: true }) expect(wrapper.emitted('undo')).toHaveLength(1) }) it('should hide the clear button when clear is false', () => { const wrapper = mountEditor({ clear: false }) expect(wrapper.find('.mint-plate-editor__action--danger').exists()).toBe(false) }) it('should emit the chosen file from import', async () => { const wrapper = mountEditor({ import: true }) const file = new File(['a,b'], 'plate.csv') const input = wrapper.get('input[type="file"]') Object.defineProperty(input.element, 'files', { value: [file] }) await input.trigger('change') expect(wrapper.emitted('import')?.[0]).toEqual([file]) }) it('should render #toolbar-extra content', () => { const wrapper = mountEditor({}, { slots: { 'toolbar-extra': () => h('span', { class: 'extra' }, 'x') } }) expect(wrapper.find('.mint-plate-editor__toolbar .extra').exists()).toBe(true) }) }) describe('PlateEditor — wells', () => { it('should switch the plate into drag mode from the Drag toggle', async () => { const wrapper = mountEditor({ wellDrag: true }) await wrapper.get('[data-action="drag"]').trigger('click') expect(plate(wrapper).props('selectionMode')).toBe('drag') }) it('should show the hint prop in the footer until drag mode replaces it', async () => { const wrapper = mountEditor({ wellDrag: true, hint: 'Drop samples here' }) expect(wrapper.get('.mint-plate-editor__hint').text()).toBe('Drop samples here') await wrapper.get('[data-action="drag"]').trigger('click') expect(wrapper.get('.mint-plate-editor__hint').text()).not.toBe('Drop samples here') }) it('should emit well-move with the active rack id', () => { const wrapper = mountEditor({ wellDrag: true }) plate(wrapper).vm.$emit('well-move', 'A1', 'B2') expect(wrapper.emitted('well-move')?.[0]).toEqual(['rack-1', 'A1', 'B2']) }) it('should emit sample-drop for a JSON sample payload', () => { const wrapper = mountEditor({ sampleDrop: true }) const parser = plate(wrapper).props('sampleDropParser') as (e: DragEvent) => unknown const data = parser(dropEvent({ 'application/json': JSON.stringify({ id: 's1', sampleName: 'Liver' }) })) expect(data).toMatchObject({ id: 's1', sampleName: 'Liver' }) }) it('should reject a plain-text drop', () => { const wrapper = mountEditor({ sampleDrop: true }) const parser = plate(wrapper).props('sampleDropParser') as (e: DragEvent) => unknown expect(parser(dropEvent({ 'text/plain': 'Liver' }))).toBeNull() }) it('should pass a custom drop parser through', () => { const custom = vi.fn(() => null) const wrapper = mountEditor({ sampleDrop: custom }) expect(plate(wrapper).props('sampleDropParser')).toBe(custom) }) it('should forward sample-drop with the active rack id', () => { const wrapper = mountEditor({ sampleDrop: true }) const event = dropEvent({}) plate(wrapper).vm.$emit('sample-drop', 'B1', { sampleName: 'Liver' }, event) expect(wrapper.emitted('sample-drop')?.[0]).toEqual(['rack-1', 'B1', { sampleName: 'Liver' }, event]) }) it('should turn off the inspector when wellFields is false', () => { const wrapper = mountEditor({ wellFields: false }) expect(plate(wrapper).props('editable')).toBe(false) }) it('should pass rack-aware props to the #well-editor slot', async () => { const wrapper = mountEditor({}, { slots: { 'well-editor': (props: { rackId: string, wellId: string }) => h('i', { class: 'slot' }, `${props.rackId}/${props.wellId}`) }, }) await wrapper.get('[role="gridcell"]').trigger('click') expect(wrapper.get('.slot').text()).toBe('rack-1/A1') }) }) describe('PlateEditor — groups', () => { const groups = [ { id: 'ctrl', name: 'Control' }, { id: 'trt', name: 'Treated', color: 'var(--mint-sample-3)' }, ] it('should show the selection bar once wells are selected', async () => { const wrapper = mountEditor({ groups }) plate(wrapper).vm.$emit('selection-change', ['A1', 'A2']) await nextTick() expect(wrapper.get('.mint-plate-editor__selection').text()).toContain('2 selected') }) it('should emit assign with the chosen group', async () => { const wrapper = mountEditor({ groups }) plate(wrapper).vm.$emit('selection-change', ['A1', 'A2']) await nextTick() await wrapper.findAll('.mint-plate-editor__group')[1].trigger('click') expect(wrapper.emitted('assign')?.[0]).toEqual(['rack-1', ['A1', 'A2'], 'trt']) }) it('should emit assign without a group from the clear button', async () => { const wrapper = mountEditor({ groups }) plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() await wrapper.get('[aria-label="Clear selected wells"]').trigger('click') expect(wrapper.emitted('assign')?.[0]).toEqual(['rack-1', ['A1'], undefined]) }) it('should drop the selection bar on cancel', async () => { const wrapper = mountEditor({ groups }) plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() await wrapper.get('[aria-label="Cancel selection"]').trigger('click') expect(wrapper.find('.mint-plate-editor__selection').exists()).toBe(false) }) it('should color wells by group id', () => { const wrapper = mountEditor({ groups }) expect(plate(wrapper).props('sampleColors')).toEqual({ ctrl: 'var(--mint-sample-1)', trt: 'var(--mint-sample-3)' }) }) it('should emit selection-change', () => { const wrapper = mountEditor({ groups }) plate(wrapper).vm.$emit('selection-change', ['A1']) expect(wrapper.emitted('selection-change')?.[0]).toEqual([['A1']]) }) it('should ignore selection without groups', async () => { const wrapper = mountEditor() plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() expect(wrapper.find('.mint-plate-editor__selection').exists()).toBe(false) }) }) describe('PlateEditor — groups and the inspector', () => { const groups = [{ id: 'ctrl', name: 'Control' }] const grouped = (): Rack[] => [ { id: 'rack-1', name: 'Rack 1', format: 96, slot: 'R', injectionVolume: 5, wells: { A1: { sampleType: 'sample', group: 'ctrl', state: 'filled' } } }, { id: 'rack-2', name: 'Rack 2', format: 96, slot: 'G', injectionVolume: 5, wells: {} }, ] it('should keep a grouped well saved without a name', () => { const wrapper = mountEditor({ modelValue: grouped(), groups }) plate(wrapper).vm.$emit('well-edit', 'A1', { ...editData, wellId: 'A1', label: '', sampleType: 'sample' }) expect(wrapper.emitted('well-clear')).toBeUndefined() }) it('should keep the type field in the inspector when there are groups', () => { const wrapper = mountEditor({ groups }) expect(plate(wrapper).props('editFields')).toContain('sampleType') }) it('should clear the selection when the plate format changes', async () => { const wrapper = mountEditor({ modelValue: grouped(), groups, formats: [96, 384] }) plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() await wrapper.setProps({ modelValue: grouped().map(rack => ({ ...rack, format: 384 as const })) }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([[]]) }) it('should report the cleared selection when switching plates', async () => { const wrapper = mountEditor({ modelValue: grouped(), groups }) plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() await tabs(wrapper)[1].trigger('click') expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([[]]) }) }) describe('PlateEditor — history and import / export (7a)', () => { it('should hide undo and redo without history', () => { const wrapper = mountEditor() expect(wrapper.find('[aria-label="Redo"]').exists()).toBe(false) }) it('should show redo with history', () => { const wrapper = mountEditor({ history: true }) expect(wrapper.find('[aria-label="Redo"]').exists()).toBe(true) }) it('should disable undo when canUndo is false', () => { const wrapper = mountEditor({ history: true }) expect(wrapper.get('[aria-label="Undo"]').attributes('disabled')).toBeDefined() }) it('should keep focus in the editor when undo runs out of steps', async () => { const wrapper = mountEditor({ history: true, canUndo: true }, { attachTo: document.body }) ;(wrapper.get('[aria-label="Undo"]').element as HTMLElement).focus() await wrapper.setProps({ canUndo: false }) expect(document.activeElement).toBe(wrapper.get('.mint-plate-editor').element) wrapper.unmount() }) it('should keep focus in the editor when redo runs out of steps', async () => { const wrapper = mountEditor({ history: true, canRedo: true }, { attachTo: document.body }) ;(wrapper.get('[aria-label="Redo"]').element as HTMLElement).focus() await wrapper.setProps({ canRedo: false }) expect(document.activeElement).toBe(wrapper.get('.mint-plate-editor').element) wrapper.unmount() }) it('should emit redo from the redo button', async () => { const wrapper = mountEditor({ history: true, canRedo: true }) await wrapper.get('[aria-label="Redo"]').trigger('click') expect(wrapper.emitted('redo')).toHaveLength(1) }) it('should emit redo on Ctrl+Shift+Z inside the editor', async () => { const wrapper = mountEditor({ history: true, canRedo: true }) await wrapper.get('.mint-plate-editor').trigger('keydown', { key: 'Z', ctrlKey: true, shiftKey: true }) expect(wrapper.emitted('redo')).toHaveLength(1) }) it('should leave Ctrl+Z in a text field to the field', async () => { const wrapper = mountEditor({ history: true, canUndo: true }, { slots: { 'toolbar-extra': () => h('input', { class: 'field' }) }, }) await wrapper.get('.field').trigger('keydown', { key: 'z', ctrlKey: true }) expect(wrapper.emitted('undo')).toBeUndefined() }) it('should label the menu Import / Export when both are on', () => { const wrapper = mountEditor({ import: true, export: ['csv'] }) expect(wrapper.get('[data-action="import-export"]').text()).toBe('Import / Export') }) it('should emit export with the chosen format', () => { const wrapper = mountEditor({ export: ['csv', 'json'] }) wrapper.findComponent(ActionMenu).vm.$emit('select', { id: 'export:json', label: 'Export JSON' }) expect(wrapper.emitted('export')?.[0]).toEqual(['json']) }) it('should open the file picker from the import menu item', () => { const wrapper = mountEditor({ import: true, export: ['csv'] }) const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {}) wrapper.findComponent(ActionMenu).vm.$emit('select', { id: 'import', label: 'Import file' }) expect(click).toHaveBeenCalledOnce() click.mockRestore() }) }) describe('PlateEditor — paste import', () => { function pasteDialog(wrapper: ReturnType) { return wrapper.findAllComponents(ConfirmDialog).find(dialog => dialog.props('title') === 'Paste wells')! } async function openPaste(wrapper: ReturnType) { wrapper.findComponent(ActionMenu).vm.$emit('select', { id: 'paste', label: 'Paste…' }) await nextTick() } it('should offer Paste in the menu when importMode is both', () => { const wrapper = mountEditor({ import: true, importMode: 'both' }) expect(wrapper.getComponent(ActionMenu).props('items').map((item: { id: string }) => item.id)).toEqual(['import', 'paste']) }) it('should label the menu Import when only imports are offered', () => { const wrapper = mountEditor({ import: true, importMode: 'both' }) expect(wrapper.get('[data-action="import-export"]').text()).toBe('Import') }) it('should drop the file picker when importMode is paste', () => { const wrapper = mountEditor({ import: true, importMode: 'paste', export: ['csv'] }) expect(wrapper.find('input[type="file"]').exists()).toBe(false) }) it('should open the paste dialog from the Paste menu item', async () => { const wrapper = mountEditor({ import: true, importMode: 'paste', export: ['csv'] }) await openPaste(wrapper) expect(pasteDialog(wrapper).props('modelValue')).toBe(true) }) it('should emit import-text with the pasted text on confirm', async () => { const wrapper = mountEditor({ import: true, importMode: 'paste', export: ['csv'] }) await openPaste(wrapper) await wrapper.get('textarea').setValue('A1\tS001') pasteDialog(wrapper).vm.$emit('confirm') expect(wrapper.emitted('import-text')?.[0]).toEqual(['A1\tS001']) }) it('should not call a File import listener when text is pasted', async () => { const files: File[] = [] // Typed as the file-only listener callers had before paste existed; it must still type-check. const wrapper = mount(PlateEditor, { props: { modelValue: makeRacks(), import: true, importMode: 'both', onImport: (file: File) => { files.push(file) } }, global: { stubs: { teleport: true } }, }) await openPaste(wrapper) await wrapper.get('textarea').setValue('A1\tS001') pasteDialog(wrapper).vm.$emit('confirm') expect(files).toEqual([]) }) it('should not emit import-text when the pasted text is blank', async () => { const wrapper = mountEditor({ import: true, importMode: 'paste', export: ['csv'] }) await openPaste(wrapper) await wrapper.get('textarea').setValue(' ') pasteDialog(wrapper).vm.$emit('confirm') expect(wrapper.emitted('import-text')).toBeUndefined() }) it('should close the paste dialog on confirm', async () => { const wrapper = mountEditor({ import: true, importMode: 'paste', export: ['csv'] }) await openPaste(wrapper) await wrapper.get('textarea').setValue('A1') pasteDialog(wrapper).vm.$emit('confirm') await nextTick() expect(pasteDialog(wrapper).props('modelValue')).toBe(false) }) it('should open an empty paste dialog when the previous paste was canceled', async () => { const wrapper = mountEditor({ import: true, importMode: 'paste', export: ['csv'] }) await openPaste(wrapper) await wrapper.get('textarea').setValue('A1\tS001') pasteDialog(wrapper).vm.$emit('update:modelValue', false) await nextTick() await openPaste(wrapper) expect((wrapper.get('textarea').element as HTMLTextAreaElement).value).toBe('') }) }) describe('PlateEditor — Shift-click range', () => { it('should emit the range between the edited well and a Shift-clicked well', async () => { const wrapper = mountEditor({ groups: [{ id: 'ctrl', name: 'Control' }] }) await cell(wrapper, 'A1').trigger('click') await cell(wrapper, 'B2').trigger('click', { shiftKey: true }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([['A1', 'A2', 'B1', 'B2']]) }) it('should not range from a well clicked on another rack', async () => { const wrapper = mountEditor({ groups: [{ id: 'ctrl', name: 'Control' }], activeRackId: 'rack-1' }) await cell(wrapper, 'A1').trigger('click') await wrapper.setProps({ activeRackId: 'rack-2' }) await cell(wrapper, 'C3').trigger('click', { shiftKey: true }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([['C3']]) }) }) describe('PlateEditor — compact wells (7a)', () => { it('should default to the xs well size', () => { const wrapper = mountEditor() expect(plate(wrapper).props('size')).toBe('xs') }) it('should hide well labels at the xs size', () => { const wrapper = mountEditor() expect(plate(wrapper).props('showWellLabels')).toBe(false) }) }) describe('PlateEditor — rail (7a)', () => { const groups = [ { id: 'ctrl', name: 'Control' }, { id: 'trt', name: 'Treated' }, ] const grouped = (): Rack[] => [ { id: 'rack-1', name: 'P1', format: 96, slot: 'R', injectionVolume: 5, wells: { A1: { sampleType: 'sample', group: 'ctrl', state: 'filled' }, A2: { sampleType: 'qc', group: 'ctrl', state: 'filled' } } }, ] it('should collapse the rail in a narrow container', () => { const wrapper = mountEditor({ groups }) expect(wrapper.find('.mint-plate-editor__rail').exists()).toBe(false) }) it('should show the rail in a wide container', async () => { const wrapper = await mountWide({ groups }) expect(wrapper.find('.mint-plate-editor__rail').exists()).toBe(true) }) it('should count the wells of each group in the rail', async () => { const wrapper = await mountWide({ modelValue: grouped(), groups }) expect(wrapper.get('.mint-plate-editor__rail-row .mint-plate-editor__rail-count').text()).toBe('2') }) it('should emit group-add from the New group row', async () => { const wrapper = await mountWide({ groups }) await wrapper.get('[data-action="group-add"]').trigger('click') expect(wrapper.emitted('group-add')).toHaveLength(1) }) it('should hide New group when readonly', async () => { const wrapper = await mountWide({ groups, readonly: true }) expect(wrapper.find('[data-action="group-add"]').exists()).toBe(false) }) it('should list the sample types, IQC included, when there are no groups', async () => { const wrapper = await mountWide() expect(wrapper.get('.mint-plate-editor__rail').text()).toContain('IQC') }) it('should move the legend from under the plate into the rail', async () => { const wrapper = await mountWide({ groups }) expect(plate(wrapper).props('showLegend')).toBe(false) }) it('should keep the legend under the plate in a narrow container', () => { const wrapper = mountEditor({ groups }) expect(plate(wrapper).props('showLegend')).toBe(true) }) it('should not open the popover inspector beside a rail', async () => { const wrapper = await mountWide() expect(plate(wrapper).props('editable')).toBe(false) }) it('should select only the clicked well on a plain click without groups', async () => { const wrapper = await mountWide() await cell(wrapper, 'A1').trigger('click', { ctrlKey: true }) await cell(wrapper, 'A2').trigger('click') expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([['A2']]) }) it('should add a well to the selection on Ctrl-click beside the rail without groups', async () => { const wrapper = await mountWide() await cell(wrapper, 'A1').trigger('click') await cell(wrapper, 'A2').trigger('click', { ctrlKey: true }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([['A1', 'A2']]) }) it('should add a well to the selection on Cmd-click beside the rail with groups', async () => { const wrapper = await mountWide({ groups }) await cell(wrapper, 'A1').trigger('click') await cell(wrapper, 'A2').trigger('click', { metaKey: true }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([['A1', 'A2']]) }) it('should select wells by rectangle with groups', async () => { const wrapper = await mountWide({ groups }) expect(plate(wrapper).props('selectionMode')).toBe('rectangle') }) it('should show the inline inspector for one selected well', async () => { const wrapper = await mountWide() plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() expect(wrapper.getComponent(WellEditPopupInternal).props('inline')).toBe(true) }) it('should emit well-edit from the inline inspector', async () => { const wrapper = await mountWide() plate(wrapper).vm.$emit('selection-change', ['A2']) await nextTick() wrapper.getComponent(WellEditPopupInternal).vm.$emit('save', editData) expect(wrapper.emitted('well-edit')?.[0]).toEqual(['rack-1', 'A2', editData]) }) it('should deselect when the inline inspector closes', async () => { const wrapper = await mountWide() plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() wrapper.getComponent(WellEditPopupInternal).vm.$emit('close') expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([[]]) }) it('should deselect when Enter is pressed in the rail inspector name field', async () => { const wrapper = await mountWide() plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() await wrapper.get('.mint-plate-editor__rail [data-field="label"] input').trigger('keydown', { key: 'Enter' }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([[]]) }) it('should save the edit to the inspected well when Enter closes the rail inspector', async () => { const wrapper = await mountWide() plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() const input = wrapper.get('.mint-plate-editor__rail [data-field="label"] input') await input.setValue('S-0099') await input.trigger('keydown', { key: 'Enter' }) expect(wrapper.emitted('well-edit')?.at(-1)).toEqual(['rack-1', 'A1', expect.objectContaining({ label: 'S-0099' })]) }) it('should deselect when Clear well is pressed in the rail inspector', async () => { const wrapper = await mountWide() plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() await wrapper.get('.mint-plate-editor__rail [data-action="clear"]').trigger('click') expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([[]]) }) it('should drop the selection when the rail collapses without groups', async () => { let onResize: ((entries: Array<{ contentRect: { width: number } }>) => void) | undefined vi.stubGlobal('ResizeObserver', class { constructor(callback: typeof onResize) { onResize = callback } observe() {} disconnect() {} }) try { const wrapper = await mountWide() plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() onResize?.([{ contentRect: { width: 640 } }]) await nextTick() expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([[]]) } finally { vi.unstubAllGlobals() } }) it('should not select a well on a plain click beside the rail when wellFields is false and there are no groups', async () => { const wrapper = await mountWide({ wellFields: false }) await cell(wrapper, 'A1').trigger('click') expect(wrapper.emitted('selection-change')).toBeUndefined() }) it('should keep the selection bar for several wells', async () => { const wrapper = await mountWide({ groups }) plate(wrapper).vm.$emit('selection-change', ['A1', 'A2']) await nextTick() expect(wrapper.findComponent(WellEditPopupInternal).exists()).toBe(false) }) it('should offer the group bar for one selected well beside the rail', async () => { const wrapper = await mountWide({ groups }) plate(wrapper).vm.$emit('selection-change', ['D5']) await nextTick() await wrapper.get('.mint-plate-editor__group').trigger('click') expect(wrapper.emitted('assign')?.[0]).toEqual(['rack-1', ['D5'], 'ctrl']) }) it('should drop the selection when the editor turns readonly', async () => { const wrapper = await mountWide() plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() await wrapper.setProps({ readonly: true }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([[]]) }) it('should keep the rail off while a 384-well plate would not fit beside it', async () => { const wrapper = await mountWide({ modelValue: [{ id: 'rack-1', name: 'P1', format: 384, slot: 'R', injectionVolume: 5, wells: {} }] }) expect(wrapper.find('.mint-plate-editor__rail').exists()).toBe(false) }) it('should show the rail once a 384-well plate fits beside it', async () => { const spy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 1200 } as DOMRect) try { const wrapper = mountEditor({ modelValue: [{ id: 'rack-1', name: 'P1', format: 384, slot: 'R', injectionVolume: 5, wells: {} }] }) await nextTick() expect(wrapper.find('.mint-plate-editor__rail').exists()).toBe(true) } finally { spy.mockRestore() } }) it('should show the rail beside a 96-well plate at size fill', async () => { const wrapper = await mountWide({ modelValue: grouped(), size: 'fill' }) expect(wrapper.find('.mint-plate-editor__rail').exists()).toBe(true) }) it('should render #well-editor in the rail with inline set', async () => { const wrapper = await mountWide({}, { slots: { 'well-editor': (props: { wellId: string, inline: boolean }) => h('i', { class: 'slot' }, `${props.wellId}/${props.inline}`) }, }) plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() expect(wrapper.get('.mint-plate-editor__rail .slot').text()).toBe('A1/true') }) }) describe('PlateEditor — inspector marker (7a)', () => { it('should tint the rail inspector marker with the group color', async () => { const racks: Rack[] = [{ id: 'rack-1', name: 'P1', format: 96, slot: 'R', injectionVolume: 5, wells: { A1: { sampleType: 'sample', group: 'trt', state: 'filled' } } }] const wrapper = await mountWide({ modelValue: racks, groups: [{ id: 'ctrl', name: 'Control' }, { id: 'trt', name: 'Treated' }] }) plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() expect(wrapper.getComponent(WellEditPopupInternal).props('markerColors')).toMatchObject({ trt: 'var(--mint-sample-2)' }) }) it('should name the group of the well in the rail inspector', async () => { const racks: Rack[] = [{ id: 'rack-1', name: 'P1', format: 96, slot: 'R', injectionVolume: 5, wells: { A1: { sampleType: 'sample', group: 'trt', state: 'filled' } } }] const wrapper = await mountWide({ modelValue: racks, groups: [{ id: 'ctrl', name: 'Control' }, { id: 'trt', name: 'Treated' }] }) plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() expect(wrapper.getComponent(WellEditPopupInternal).props('groupLabel')).toBe('Treated') }) it('should give the rail inspector the Type field when there are groups', async () => { const wrapper = await mountWide({ groups: [{ id: 'ctrl', name: 'Control' }] }) plate(wrapper).vm.$emit('selection-change', ['A1']) await nextTick() expect(wrapper.getComponent(WellEditPopupInternal).props('editFields')).toContain('sampleType') }) }) describe('PlateEditor — modifier clicks in the popover mode', () => { it('should add a well to the selection on Ctrl-click without groups', async () => { const wrapper = mountEditor() await cell(wrapper, 'A1').trigger('click', { ctrlKey: true }) await cell(wrapper, 'A2').trigger('click', { ctrlKey: true }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([['A1', 'A2']]) }) it('should toggle a well out of the selection on Cmd-click without groups', async () => { const wrapper = mountEditor() await cell(wrapper, 'A1').trigger('click', { metaKey: true }) await cell(wrapper, 'A1').trigger('click', { metaKey: true }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([[]]) }) it('should keep opening the popover on a plain click without groups', async () => { const wrapper = mountEditor() await cell(wrapper, 'A1').trigger('click') expect(wrapper.findComponent(WellEditPopupInternal).exists()).toBe(true) }) it('should not select a well on a plain click when wellFields is false and there are no groups', async () => { const wrapper = mountEditor({ wellFields: false }) await cell(wrapper, 'A1').trigger('click') expect(wrapper.emitted('selection-change')).toBeUndefined() }) it('should add a well to the selection on Ctrl-click with groups', async () => { const wrapper = mountEditor({ groups: [{ id: 'ctrl', name: 'Control' }] }) await cell(wrapper, 'A1').trigger('click', { ctrlKey: true }) await cell(wrapper, 'A2').trigger('click', { ctrlKey: true }) expect(wrapper.emitted('selection-change')?.at(-1)).toEqual([['A1', 'A2']]) }) }) describe('PlateEditor — designer options', () => { const fullRack = (): Rack[] => [{ id: 'rack-1', name: 'Rack 1', format: 6, slot: 'R', injectionVolume: 5, wells: Object.fromEntries(['A1', 'A2', 'A3', 'B1', 'B2', 'B3'].map(id => [id, { sampleType: 'sample' }])), }] it('should use the clearConfirm title in the confirmation', () => { const wrapper = mountEditor({ clearConfirm: { title: 'Unplace samples' } }) expect(wrapper.getComponent(ConfirmDialog).props('title')).toBe('Unplace samples') }) it('should use the clearConfirm message in the confirmation', () => { const wrapper = mountEditor({ clearConfirm: { message: 'All 1 placed samples go back to the Sample List unplaced.' } }) expect(wrapper.getComponent(ConfirmDialog).props('message')).toBe('All 1 placed samples go back to the Sample List unplaced.') }) it('should use the clearConfirm confirm label in the confirmation', () => { const wrapper = mountEditor({ clearConfirm: { confirmLabel: 'Unplace all' } }) expect(wrapper.getComponent(ConfirmDialog).props('confirmLabel')).toBe('Unplace all') }) it('should pass the clearConfirm variant to the confirmation', () => { const wrapper = mountEditor({ clearConfirm: { variant: 'warning' } }) expect(wrapper.getComponent(ConfirmDialog).props('variant')).toBe('warning') }) it('should keep the default clear message without clearConfirm', () => { const wrapper = mountEditor() expect(wrapper.getComponent(ConfirmDialog).props('message')).toBe('Clear all 1 wells on Rack 1?') }) it('should keep fill-series enabled on a full plate when fillSeriesWhenFull is emit', () => { const wrapper = mountEditor({ modelValue: fullRack(), fillSeries: true, fillSeriesWhenFull: 'emit' }) expect(wrapper.get('[data-action="fill-series"]').attributes('disabled')).toBeUndefined() }) it('should emit fill-series on a full plate when fillSeriesWhenFull is emit', async () => { const wrapper = mountEditor({ modelValue: fullRack(), fillSeries: true, fillSeriesWhenFull: 'emit' }) await wrapper.get('[data-action="fill-series"]').trigger('click') expect(wrapper.emitted('fill-series')?.[0]).toEqual(['rack-1']) }) it('should set the file input accept from importAccept', () => { const wrapper = mountEditor({ import: true, importAccept: '.csv,text/csv' }) expect(wrapper.get('input[type="file"]').attributes('accept')).toBe('.csv,text/csv') }) })