import { describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' import { mount } from '@vue/test-utils' import WellPlate from '../../components/WellPlate.vue' import WellEditPopupInternal from '../../components/internal/WellEditPopupInternal.vue' import { nextClickedWellSelection, useWellPlateInteraction, } from '../../components/WellPlate.interaction' import type { Well, WellPlateSelectionMode } from '../../types' function well(overrides: Partial = {}): Well { return { id: 'A1', row: 0, col: 0, state: 'empty', ...overrides, } } function mouseEvent(overrides: Partial = {}): MouseEvent { return { preventDefault: vi.fn(), button: 0, shiftKey: false, metaKey: false, ctrlKey: false, currentTarget: { getBoundingClientRect: () => ({ right: 10, top: 20 }), }, ...overrides, } as unknown as MouseEvent } function dragEvent(data: Record = {}): DragEvent { return { preventDefault: vi.fn(), dataTransfer: { effectAllowed: '', dropEffect: '', getData: (format: string) => data[format] ?? '', setData: vi.fn(), }, } as unknown as DragEvent } function keyEvent(key: string, altKey = true): KeyboardEvent { return { key, altKey, preventDefault: vi.fn(), } as unknown as KeyboardEvent } function createInteraction(overrides: Partial[0]> = {}) { const emitted: Record = {} const selected = { value: [] as string[] } const selectionMode = { value: 'multiple' as WellPlateSelectionMode } const rows = ['A', 'B'] const grid = [ [well({ id: 'A1', row: 0, col: 0 }), well({ id: 'A2', row: 0, col: 1 })], [well({ id: 'B1', row: 1, col: 0 }), well({ id: 'B2', row: 1, col: 1 })], ] const interaction = useWellPlateInteraction({ modelValue: () => selected.value, selectionMode: () => selectionMode.value, disabled: () => false, readonly: () => false, editable: () => false, allowSampleDrop: () => false, sampleDropParser: () => undefined, rowLabels: () => rows, wellGrid: () => grid, emitSelected: (wellIds) => { emitted.selected = [...(emitted.selected ?? []), wellIds] selected.value = wellIds }, emitWellClick: (wellId, event) => { emitted.click = [...(emitted.click ?? []), [wellId, event]] }, emitWellHover: (wellId, event) => { emitted.hover = [...(emitted.hover ?? []), [wellId, event]] }, emitContextMenu: (wellId, event) => { emitted.context = [...(emitted.context ?? []), [wellId, event]] }, emitWellMove: (sourceWellId, targetWellId) => { emitted.move = [...(emitted.move ?? []), [sourceWellId, targetWellId]] }, emitWellEdit: (wellId, data) => { emitted.edit = [...(emitted.edit ?? []), [wellId, data]] }, emitWellClear: (wellId) => { emitted.clear = [...(emitted.clear ?? []), wellId] }, emitSampleDrop: (wellId, data, event) => { emitted.drop = [...(emitted.drop ?? []), [wellId, data, event]] }, ...overrides, }) return { emitted, grid, interaction, selected, selectionMode } } describe('WellPlate interaction helpers', () => { it('calculates clicked well selection for single and multi-select modes', () => { expect(nextClickedWellSelection([], 'A1', 'single', mouseEvent())).toEqual(['A1']) expect(nextClickedWellSelection(['A1'], 'A1', 'single', mouseEvent())).toEqual([]) expect(nextClickedWellSelection(['A1'], 'A2', 'multiple', mouseEvent({ shiftKey: true }))).toEqual(['A1', 'A2']) expect(nextClickedWellSelection(['A1', 'A2'], 'A1', 'multiple', mouseEvent({ ctrlKey: true }))).toEqual(['A2']) expect(nextClickedWellSelection(['A1'], 'A1', 'multiple', mouseEvent())).toEqual([]) }) it('emits click selection changes and opens editor instead when editable', () => { const { emitted, grid, interaction } = createInteraction() interaction.handleWellClick(grid[0][0], mouseEvent()) expect(emitted.click?.[0]).toEqual(['A1', expect.anything()]) expect(emitted.selected?.[0]).toEqual(['A1']) const editable = createInteraction({ editable: () => true }) editable.interaction.handleWellClick(grid[0][1], mouseEvent()) expect(editable.interaction.editingWellId.value).toBe('A2') expect(editable.interaction.editPopupPosition.value).toEqual({ x: 18, y: 20 }) expect(editable.emitted.selected).toBeUndefined() }) it('tracks rectangle drag selection and emits on mouseup', () => { const { emitted, grid, interaction, selectionMode } = createInteraction() selectionMode.value = 'rectangle' interaction.handleWellMouseDown(grid[0][0], mouseEvent()) interaction.handleWellMouseEnter(grid[1][1], mouseEvent()) expect(Array.from(interaction.dragSelectedWells.value)).toEqual(['A1', 'A2', 'B1', 'B2']) interaction.handleMouseUp() expect(emitted.selected?.[0]).toEqual(['A1', 'A2', 'B1', 'B2']) expect(interaction.isDragging.value).toBe(false) }) it('should select a well when it is clicked in rectangle mode', () => { const { grid, interaction, selected, selectionMode } = createInteraction() selectionMode.value = 'rectangle' interaction.handleWellMouseDown(grid[0][0], mouseEvent()) interaction.handleMouseUp() interaction.handleWellClick(grid[0][0], mouseEvent()) expect(selected.value).toEqual(['A1']) }) it('should add a well to the selection when it is shift-clicked in rectangle mode', () => { const { grid, interaction, selected, selectionMode } = createInteraction() selectionMode.value = 'rectangle' selected.value = ['A1'] interaction.handleWellMouseDown(grid[1][1], mouseEvent({ shiftKey: true })) interaction.handleMouseUp() interaction.handleWellClick(grid[1][1], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['A1', 'B2']) }) it('moves wells in drag mode and resets drag state', () => { const { emitted, grid, interaction, selectionMode } = createInteraction() selectionMode.value = 'drag' interaction.handleDragStart({ ...grid[0][0], sampleType: 'sample' }, dragEvent()) interaction.handleDrop(grid[1][1], dragEvent()) expect(emitted.move?.[0]).toEqual(['A1', 'B2']) expect(interaction.dragSourceWell.value).toBeNull() expect(interaction.dragTargetWell.value).toBeNull() }) it('parses dropped samples when sample drop is enabled', () => { const { emitted, grid, interaction } = createInteraction({ allowSampleDrop: () => true }) interaction.handleDrop(grid[0][0], dragEvent({ 'text/plain': 'Sample A' })) expect(emitted.drop?.[0]).toEqual([ 'A1', { sampleName: 'Sample A', label: 'Sample A', raw: 'Sample A' }, expect.anything(), ]) }) it('handles keyboard clear and select all shortcuts', () => { const { emitted, interaction, selected } = createInteraction() selected.value = ['A1'] interaction.handleKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })) interaction.handleKeyDown(new KeyboardEvent('keydown', { key: 'a', metaKey: true })) expect(emitted.selected).toEqual([ [], ['A1', 'A2', 'B1', 'B2'], ]) }) it('moves a well with the keyboard pick-up/drop chord in drag mode', async () => { const { emitted, interaction, selectionMode } = createInteraction() selectionMode.value = 'drag' const source = well({ id: 'A1', row: 0, col: 0, sampleType: 'sample' }) const target = well({ id: 'B2', row: 1, col: 1 }) interaction.handleWellMoveKeydown(source, keyEvent('Enter')) expect(interaction.dragSourceWell.value).toBe('A1') interaction.handleWellMoveKeydown(target, keyEvent('Enter')) expect(emitted.move).toEqual([['A1', 'B2']]) expect(interaction.dragSourceWell.value).toBeNull() await nextTick() expect(interaction.moveAnnouncement.value).toBe('Well A1 moved to B2.') }) it('cancels a keyboard pick-up with Escape and refuses empty wells', () => { const { emitted, interaction, selectionMode } = createInteraction() selectionMode.value = 'drag' interaction.handleWellMoveKeydown(well({ id: 'A2', row: 0, col: 1 }), keyEvent('Enter')) expect(interaction.dragSourceWell.value).toBeNull() interaction.handleWellMoveKeydown( well({ id: 'A1', row: 0, col: 0, sampleType: 'sample' }), keyEvent('Enter'), ) expect(interaction.dragSourceWell.value).toBe('A1') interaction.handleWellMoveKeydown( well({ id: 'A1', row: 0, col: 0, sampleType: 'sample' }), keyEvent('Escape', false), ) expect(interaction.dragSourceWell.value).toBeNull() expect(emitted.move).toBeUndefined() }) it('ignores the move chord outside drag selection mode', () => { const { interaction } = createInteraction() interaction.handleWellMoveKeydown( well({ id: 'A1', row: 0, col: 0, sampleType: 'sample' }), keyEvent('Enter'), ) expect(interaction.dragSourceWell.value).toBeNull() }) }) describe('WellPlate interaction for PlateEditor (7a)', () => { it('should toggle the selection instead of opening the editor when a modifier click lands on an editable plate', () => { const { emitted, grid, interaction } = createInteraction({ editable: () => true }) interaction.handleWellClick(grid[0][0], mouseEvent({ ctrlKey: true })) expect(emitted.selected?.[0]).toEqual(['A1']) }) it('should not open the editor when a modifier click lands on an editable plate', () => { const { grid, interaction } = createInteraction({ editable: () => true }) interaction.handleWellClick(grid[0][0], mouseEvent({ metaKey: true })) expect(interaction.editingWellId.value).toBeNull() }) it('should emit well-click when the plate is readonly', () => { const { emitted, grid, interaction } = createInteraction({ readonly: () => true }) interaction.handleWellClick(grid[0][0], mouseEvent()) expect(emitted.click?.[0]).toEqual(['A1', expect.anything()]) }) it('should not change the selection when a readonly plate is clicked', () => { const { emitted, grid, interaction } = createInteraction({ readonly: () => true }) interaction.handleWellClick(grid[0][0], mouseEvent()) expect(emitted.selected).toBeUndefined() }) }) describe('WellPlate well names', () => { it('should name the sample in the well label even when names are not drawn', () => { const wrapper = mount(WellPlate, { props: { format: 6, showWellLabels: false, wells: { A1: { sampleType: 'sample', metadata: { label: 'S001' } } } }, }) expect(wrapper.find('[aria-label="Well A1, Name: S001, Sample type: sample"]').exists()).toBe(true) }) const grouped = { format: 6 as const, legendItems: [{ type: 'ctrl', label: 'Control', color: 'red' }], wells: { A1: { sampleType: 'qc', group: 'ctrl' } }, } it('should name the group in the well label when the well has a group', () => { const wrapper = mount(WellPlate, { props: grouped }) expect(wrapper.find('[aria-label="Well A1, Sample type: qc, Group: Control"]').exists()).toBe(true) }) it('should name the group in the well tooltip when the well has a group', () => { const wrapper = mount(WellPlate, { props: grouped }) expect(wrapper.get('[role="gridcell"]').attributes('title')).toBe('A1: qc, Control') }) }) describe('WellPlate edit popover', () => { it('should not reopen a popover that closed when the plate stopped being editable', async () => { const wrapper = mount(WellPlate, { props: { format: 6, editable: true }, global: { stubs: { teleport: true } } }) await wrapper.get('[aria-label="Well B2"]').trigger('click') await wrapper.setProps({ editable: false }) await wrapper.setProps({ editable: true }) expect(wrapper.findComponent(WellEditPopupInternal).exists()).toBe(false) }) }) describe('WellPlate Shift-click range selection', () => { it('should select the rectangle from the anchor when Shift-clicking another well', () => { const { grid, interaction, selected } = createInteraction() interaction.handleWellClick(grid[0][0], mouseEvent()) interaction.handleWellClick(grid[1][1], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['A1', 'A2', 'B1', 'B2']) }) it('should keep the anchor when Shift-clicking twice', () => { const { grid, interaction, selected } = createInteraction() interaction.handleWellClick(grid[0][1], mouseEvent()) interaction.handleWellClick(grid[1][1], mouseEvent({ shiftKey: true })) interaction.handleWellClick(grid[1][0], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['A1', 'A2', 'B1', 'B2']) }) it('should move the anchor when a well is plain-clicked', () => { const { grid, interaction, selected } = createInteraction() interaction.handleWellClick(grid[0][0], mouseEvent()) interaction.handleWellClick(grid[1][0], mouseEvent()) interaction.handleWellClick(grid[1][1], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['B1', 'B2']) }) it('should toggle a well on Ctrl-click after an anchor is set', () => { const { grid, interaction, selected } = createInteraction() interaction.handleWellClick(grid[0][0], mouseEvent()) interaction.handleWellClick(grid[1][1], mouseEvent({ ctrlKey: true })) expect(selected.value).toEqual(['A1', 'B2']) }) it('should toggle a well on Shift-click when no anchor is set', () => { const { grid, interaction, selected } = createInteraction() selected.value = ['A1'] interaction.handleWellClick(grid[1][1], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['A1', 'B2']) }) it('should drop the anchor when the selection is cleared', async () => { const { grid, interaction, selected } = createInteraction() interaction.handleWellClick(grid[0][0], mouseEvent()) interaction.handleKeyDown({ key: 'Escape' } as KeyboardEvent) await nextTick() interaction.handleWellClick(grid[1][1], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['B2']) }) it('should ignore Shift ranges in single selection mode', () => { const { grid, interaction, selected, selectionMode } = createInteraction() selectionMode.value = 'single' interaction.handleWellClick(grid[0][0], mouseEvent()) interaction.handleWellClick(grid[1][1], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['B2']) }) it('should range from the start of a dragged rectangle', () => { const { grid, interaction, selected, selectionMode } = createInteraction() selectionMode.value = 'rectangle' interaction.handleWellClick(grid[1][1], mouseEvent()) interaction.handleWellMouseDown(grid[0][0], mouseEvent()) interaction.handleWellMouseEnter(grid[0][1], mouseEvent()) interaction.handleMouseUp() interaction.handleWellClick(grid[1][0], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['A1', 'B1']) }) it('should add the range to the selection when Ctrl+Shift-clicking', () => { const { grid, interaction, selected } = createInteraction() interaction.handleWellClick(grid[0][0], mouseEvent()) interaction.handleWellClick(grid[1][0], mouseEvent({ ctrlKey: true })) interaction.handleWellClick(grid[1][1], mouseEvent({ ctrlKey: true, shiftKey: true })) expect(selected.value).toEqual(['A1', 'B1', 'B2']) }) it('should range from the well whose editor a plain click opened', () => { const { grid, interaction, selected } = createInteraction({ editable: () => true }) interaction.handleWellClick(grid[0][0], mouseEvent()) interaction.handleWellClick(grid[0][1], mouseEvent({ shiftKey: true })) expect(selected.value).toEqual(['A1', 'A2']) }) it('should emit the range on Shift+Enter from a focused well', async () => { const wrapper = mount(WellPlate, { props: { format: 6 } }) const cells = wrapper.findAll('[role="gridcell"]') await cells[0].trigger('keydown', { key: 'Enter' }) await wrapper.setProps({ modelValue: ['A1'] }) await cells[4].trigger('keydown', { key: 'Enter', shiftKey: true }) expect(wrapper.emitted('selection-change')?.at(-1)?.[0]).toEqual(['A1', 'A2', 'B1', 'B2']) }) })