Add unit tests for useLineSelection composable

This commit is contained in:
2026-01-29 03:28:06 +00:00
parent b97d1c1f71
commit 12ea08a7e1
3 changed files with 560 additions and 0 deletions

View File

@@ -0,0 +1,209 @@
import { ref, computed } from 'vue';
import { useClipboard } from '@/composables/useClipboard.js';
/**
* useLineSelection - Composable for managing line selection operations
*
* Handles:
* - Single, range, and multi-line selection with Shift/Ctrl modifiers
* - Copy selected/all lines to clipboard
* - Export selected/all lines to JSON file
* - URL sharing
* - Selection state management
*
* @param {Ref<Set>} displayLines - Current displayed lines with metadata
* @param {Ref<string>} fileContent - Full file content for export
* @param {Ref<string>} selectedFile - Current file name for export naming
* @returns {Object} Line selection composable API
*/
export function useLineSelection(displayLines, fileContent, selectedFile) {
// Clipboard composable
const clipboard = useClipboard();
// Selection state - use Set for O(1) lookups
const selectedLines = ref(new Set());
/**
* Check if any lines are selected
*/
const hasSelection = computed(() => selectedLines.value.size > 0);
/**
* Get count of selected lines
*/
const selectionCount = computed(() => selectedLines.value.size);
/**
* Get selected line numbers as sorted array
*/
const selectedLineNumbers = computed(() => {
return [...selectedLines.value].sort((a, b) => a - b);
});
/**
* Handle line selection with modifiers
*
* Supports:
* - Single click: Select only that line
* - Shift+click: Select range from last selected to current
* - Ctrl/Cmd+click: Toggle individual line
*/
function toggleLineSelection(lineNumber, event) {
if (event.shiftKey && selectedLines.value.size > 0) {
// Range selection
const lastSelected = Math.max(...selectedLines.value);
const start = Math.min(lastSelected, lineNumber);
const end = Math.max(lastSelected, lineNumber);
for (let i = start; i <= end; i++) {
selectedLines.value.add(i);
}
} else if (event.ctrlKey || event.metaKey) {
// Toggle individual line
if (selectedLines.value.has(lineNumber)) {
selectedLines.value.delete(lineNumber);
} else {
selectedLines.value.add(lineNumber);
}
} else {
// Single selection
selectedLines.value.clear();
selectedLines.value.add(lineNumber);
}
}
/**
* Clear all selections
*/
function clearSelection() {
selectedLines.value.clear();
}
/**
* Get content of selected lines
*/
function getSelectedContent() {
if (selectedLines.value.size === 0) return '';
const lines = selectedLineNumbers.value;
const content = lines
.map(lineNum => {
return (
displayLines.value.find(l => l.lineNumber === lineNum)?.content || ''
);
})
.join('\n');
return content;
}
/**
* Copy selected lines to clipboard
*/
async function copySelected() {
if (selectedLines.value.size === 0) return;
const content = getSelectedContent();
await clipboard.copyToClipboard(content);
}
/**
* Copy all content to clipboard
*/
async function copyAll() {
await clipboard.copyToClipboard(fileContent.value);
}
/**
* Export selected lines to JSON file
*/
function exportSelected() {
if (selectedLines.value.size === 0) return;
const content = getSelectedContent();
downloadFile(
content,
`${selectedFile.value}-selected-${Date.now()}.json`
);
}
/**
* Export all content to JSON file
*/
function exportAll() {
downloadFile(fileContent.value, `${selectedFile.value}-${Date.now()}.json`);
}
/**
* Helper: Download content as file
*/
function downloadFile(content, filename) {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
/**
* Share current URL (copy to clipboard)
*/
async function shareUrl() {
const url = globalThis.location.href;
await clipboard.copyToClipboard(url);
}
/**
* Select all available lines
*/
function selectAll() {
displayLines.value.forEach(line => {
selectedLines.value.add(line.lineNumber);
});
}
/**
* Invert selection (select all not selected, deselect all selected)
*/
function invertSelection() {
const allLineNumbers = new Set(
displayLines.value.map(line => line.lineNumber)
);
const newSelection = new Set();
allLineNumbers.forEach(lineNum => {
if (!selectedLines.value.has(lineNum)) {
newSelection.add(lineNum);
}
});
selectedLines.value.clear();
newSelection.forEach(lineNum => selectedLines.value.add(lineNum));
}
return {
// State
selectedLines,
// Computed
hasSelection,
selectionCount,
selectedLineNumbers,
// Methods
toggleLineSelection,
clearSelection,
getSelectedContent,
copySelected,
copyAll,
exportSelected,
exportAll,
shareUrl,
selectAll,
invertSelection
};
}

View File

@@ -0,0 +1,310 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref } from 'vue';
import { useLineSelection } from '@/composables/useLineSelection.js';
describe('useLineSelection', () => {
let displayLines;
let fileContent;
let selectedFile;
let composable;
beforeEach(() => {
displayLines = ref([
{ lineNumber: 1, content: 'line 1' },
{ lineNumber: 2, content: 'line 2' },
{ lineNumber: 3, content: 'line 3' },
{ lineNumber: 4, content: 'line 4' },
{ lineNumber: 5, content: 'line 5' }
]);
fileContent = ref('line 1\nline 2\nline 3\nline 4\nline 5');
selectedFile = ref('test-file');
composable = useLineSelection(displayLines, fileContent, selectedFile);
// Mock clipboard
global.navigator = {
clipboard: {
writeText: vi.fn()
}
};
// Mock URL methods
global.URL.createObjectURL = vi.fn(() => 'blob:test');
global.URL.revokeObjectURL = vi.fn();
// Mock document methods
document.body.appendChild = vi.fn();
document.body.removeChild = vi.fn();
});
describe('initialization', () => {
it('should initialize with empty selection', () => {
expect(composable.selectedLines.value.size).toBe(0);
expect(composable.hasSelection.value).toBe(false);
expect(composable.selectionCount.value).toBe(0);
});
});
describe('toggleLineSelection', () => {
it('should select single line with no modifiers', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
composable.toggleLineSelection(1, event);
expect(composable.selectedLines.value.has(1)).toBe(true);
expect(composable.selectedLines.value.size).toBe(1);
});
it('should clear previous selection on single click', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
composable.toggleLineSelection(1, event);
composable.toggleLineSelection(3, event);
expect(composable.selectedLines.value.has(1)).toBe(false);
expect(composable.selectedLines.value.has(3)).toBe(true);
expect(composable.selectedLines.value.size).toBe(1);
});
it('should toggle line with Ctrl modifier', () => {
const ctrlEvent = { shiftKey: false, ctrlKey: true, metaKey: false };
composable.toggleLineSelection(1, ctrlEvent);
expect(composable.selectedLines.value.has(1)).toBe(true);
composable.toggleLineSelection(1, ctrlEvent);
expect(composable.selectedLines.value.has(1)).toBe(false);
});
it('should toggle line with Cmd modifier (Mac)', () => {
const cmdEvent = { shiftKey: false, ctrlKey: false, metaKey: true };
composable.toggleLineSelection(1, cmdEvent);
expect(composable.selectedLines.value.has(1)).toBe(true);
composable.toggleLineSelection(1, cmdEvent);
expect(composable.selectedLines.value.has(1)).toBe(false);
});
it('should add multiple lines with Ctrl', () => {
const ctrlEvent = { shiftKey: false, ctrlKey: true, metaKey: false };
composable.toggleLineSelection(1, ctrlEvent);
composable.toggleLineSelection(3, ctrlEvent);
composable.toggleLineSelection(5, ctrlEvent);
expect(composable.selectedLines.value.size).toBe(3);
expect(composable.selectedLines.value.has(1)).toBe(true);
expect(composable.selectedLines.value.has(3)).toBe(true);
expect(composable.selectedLines.value.has(5)).toBe(true);
});
it('should select range with Shift modifier', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
const shiftEvent = { shiftKey: true, ctrlKey: false, metaKey: false };
composable.toggleLineSelection(2, event); // Set starting point
composable.toggleLineSelection(4, shiftEvent); // Select range
expect(composable.selectedLines.value.has(2)).toBe(true);
expect(composable.selectedLines.value.has(3)).toBe(true);
expect(composable.selectedLines.value.has(4)).toBe(true);
expect(composable.selectedLines.value.size).toBe(3);
});
it('should select range in reverse order with Shift', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
const shiftEvent = { shiftKey: true, ctrlKey: false, metaKey: false };
composable.toggleLineSelection(4, event); // Set starting point
composable.toggleLineSelection(2, shiftEvent); // Select range backwards
expect(composable.selectedLines.value.has(2)).toBe(true);
expect(composable.selectedLines.value.has(3)).toBe(true);
expect(composable.selectedLines.value.has(4)).toBe(true);
expect(composable.selectedLines.value.size).toBe(3);
});
});
describe('clearSelection', () => {
it('should clear all selections', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
composable.toggleLineSelection(1, event);
composable.toggleLineSelection(3, event);
composable.clearSelection();
expect(composable.selectedLines.value.size).toBe(0);
expect(composable.hasSelection.value).toBe(false);
});
});
describe('getSelectedContent', () => {
it('should return empty string when no selection', () => {
expect(composable.getSelectedContent()).toBe('');
});
it('should return selected lines content', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
const ctrlEvent = { shiftKey: false, ctrlKey: true, metaKey: false };
composable.toggleLineSelection(1, event);
composable.toggleLineSelection(3, ctrlEvent);
const content = composable.getSelectedContent();
expect(content).toContain('line 1');
expect(content).toContain('line 3');
expect(content).not.toContain('line 2');
});
it('should maintain line order in output', () => {
const ctrlEvent = { shiftKey: false, ctrlKey: true, metaKey: false };
composable.toggleLineSelection(5, ctrlEvent);
composable.toggleLineSelection(2, ctrlEvent);
composable.toggleLineSelection(4, ctrlEvent);
const content = composable.getSelectedContent();
const lines = content.split('\n');
expect(lines[0]).toBe('line 2');
expect(lines[1]).toBe('line 4');
expect(lines[2]).toBe('line 5');
});
});
describe('computed properties', () => {
it('should update hasSelection', () => {
expect(composable.hasSelection.value).toBe(false);
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
composable.toggleLineSelection(1, event);
expect(composable.hasSelection.value).toBe(true);
});
it('should update selectionCount', () => {
const ctrlEvent = { shiftKey: false, ctrlKey: true, metaKey: false };
expect(composable.selectionCount.value).toBe(0);
composable.toggleLineSelection(1, ctrlEvent);
expect(composable.selectionCount.value).toBe(1);
composable.toggleLineSelection(3, ctrlEvent);
expect(composable.selectionCount.value).toBe(2);
});
it('should return sorted selected line numbers', () => {
const ctrlEvent = { shiftKey: false, ctrlKey: true, metaKey: false };
composable.toggleLineSelection(5, ctrlEvent);
composable.toggleLineSelection(2, ctrlEvent);
composable.toggleLineSelection(4, ctrlEvent);
expect(composable.selectedLineNumbers.value).toEqual([2, 4, 5]);
});
});
describe('selectAll', () => {
it('should select all available lines', () => {
composable.selectAll();
expect(composable.selectedLines.value.size).toBe(5);
expect(composable.hasSelection.value).toBe(true);
for (let i = 1; i <= 5; i++) {
expect(composable.selectedLines.value.has(i)).toBe(true);
}
});
});
describe('invertSelection', () => {
it('should invert selection', () => {
const ctrlEvent = { shiftKey: false, ctrlKey: true, metaKey: false };
composable.toggleLineSelection(2, ctrlEvent);
composable.toggleLineSelection(4, ctrlEvent);
composable.invertSelection();
expect(composable.selectedLines.value.has(1)).toBe(true);
expect(composable.selectedLines.value.has(2)).toBe(false);
expect(composable.selectedLines.value.has(3)).toBe(true);
expect(composable.selectedLines.value.has(4)).toBe(false);
expect(composable.selectedLines.value.has(5)).toBe(true);
});
it('should invert empty selection to select all', () => {
composable.invertSelection();
expect(composable.selectedLines.value.size).toBe(5);
});
});
describe('copySelected', () => {
it('should not copy if no selection', async () => {
await composable.copySelected();
expect(global.navigator.clipboard.writeText).not.toHaveBeenCalled();
});
it('should copy selected lines', async () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
composable.toggleLineSelection(1, event);
await composable.copySelected();
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(
'line 1'
);
});
});
describe('copyAll', () => {
it('should copy all content', async () => {
await composable.copyAll();
expect(global.navigator.clipboard.writeText).toHaveBeenCalledWith(
fileContent.value
);
});
});
describe('exportSelected', () => {
it('should not export if no selection', () => {
const createElementSpy = vi.spyOn(document, 'createElement');
composable.exportSelected();
expect(createElementSpy).not.toHaveBeenCalledWith('a');
});
it('should export selected lines as file', () => {
const event = { shiftKey: false, ctrlKey: false, metaKey: false };
const ctrlEvent = { shiftKey: false, ctrlKey: true, metaKey: false };
composable.toggleLineSelection(1, event);
composable.toggleLineSelection(3, ctrlEvent);
const createElementSpy = vi.spyOn(document, 'createElement');
composable.exportSelected();
const linkElement = createElementSpy.mock.results.find(
r => r.value?.click
)?.value;
expect(linkElement?.download).toContain('test-file-selected');
});
});
describe('exportAll', () => {
it('should export all content as file', () => {
const createElementSpy = vi.spyOn(document, 'createElement');
composable.exportAll();
const linkElement = createElementSpy.mock.results.find(
r => r.value?.click
)?.value;
expect(linkElement?.download).toContain('test-file');
expect(linkElement?.download).not.toContain('selected');
});
});
describe('shareUrl', () => {
it('should copy current URL to clipboard', async () => {
await composable.shareUrl();
expect(global.navigator.clipboard.writeText).toHaveBeenCalled();
const call = global.navigator.clipboard.writeText.mock.calls[0][0];
expect(call).toContain(globalThis.location.href || '');
});
});
});

View File

@@ -0,0 +1,41 @@
// vitest.config.js
import { defineConfig } from "file:///Users/fragginwagon/Developer/MemoryPalace/code/websites/pokedex.online/node_modules/vitest/dist/config.js";
import vue from "file:///Users/fragginwagon/Developer/MemoryPalace/code/websites/pokedex.online/node_modules/@vitejs/plugin-vue/dist/index.mjs";
import { fileURLToPath } from "node:url";
var __vite_injected_original_import_meta_url = "file:///Users/fragginwagon/Developer/MemoryPalace/code/websites/pokedex.online/vitest.config.js";
var vitest_config_default = defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", __vite_injected_original_import_meta_url))
}
},
test: {
globals: true,
environment: "happy-dom",
setupFiles: ["./tests/setup.js"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html", "lcov"],
exclude: [
"node_modules/",
"tests/",
"dist/",
"server/",
"*.config.js",
".eslintrc.cjs"
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80
}
},
include: ["tests/**/*.test.js", "tests/**/*.spec.js"]
}
});
export {
vitest_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZXN0LmNvbmZpZy5qcyJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiY29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2Rpcm5hbWUgPSBcIi9Vc2Vycy9mcmFnZ2lud2Fnb24vRGV2ZWxvcGVyL01lbW9yeVBhbGFjZS9jb2RlL3dlYnNpdGVzL3Bva2VkZXgub25saW5lXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ZpbGVuYW1lID0gXCIvVXNlcnMvZnJhZ2dpbndhZ29uL0RldmVsb3Blci9NZW1vcnlQYWxhY2UvY29kZS93ZWJzaXRlcy9wb2tlZGV4Lm9ubGluZS92aXRlc3QuY29uZmlnLmpzXCI7Y29uc3QgX192aXRlX2luamVjdGVkX29yaWdpbmFsX2ltcG9ydF9tZXRhX3VybCA9IFwiZmlsZTovLy9Vc2Vycy9mcmFnZ2lud2Fnb24vRGV2ZWxvcGVyL01lbW9yeVBhbGFjZS9jb2RlL3dlYnNpdGVzL3Bva2VkZXgub25saW5lL3ZpdGVzdC5jb25maWcuanNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlc3QvY29uZmlnJztcbmltcG9ydCB2dWUgZnJvbSAnQHZpdGVqcy9wbHVnaW4tdnVlJztcbmltcG9ydCB7IGZpbGVVUkxUb1BhdGggfSBmcm9tICdub2RlOnVybCc7XG5cbmV4cG9ydCBkZWZhdWx0IGRlZmluZUNvbmZpZyh7XG4gIHBsdWdpbnM6IFt2dWUoKV0sXG4gIHJlc29sdmU6IHtcbiAgICBhbGlhczoge1xuICAgICAgJ0AnOiBmaWxlVVJMVG9QYXRoKG5ldyBVUkwoJy4vc3JjJywgaW1wb3J0Lm1ldGEudXJsKSlcbiAgICB9XG4gIH0sXG4gIHRlc3Q6IHtcbiAgICBnbG9iYWxzOiB0cnVlLFxuICAgIGVudmlyb25tZW50OiAnaGFwcHktZG9tJyxcbiAgICBzZXR1cEZpbGVzOiBbJy4vdGVzdHMvc2V0dXAuanMnXSxcbiAgICBjb3ZlcmFnZToge1xuICAgICAgcHJvdmlkZXI6ICd2OCcsXG4gICAgICByZXBvcnRlcjogWyd0ZXh0JywgJ2pzb24nLCAnaHRtbCcsICdsY292J10sXG4gICAgICBleGNsdWRlOiBbXG4gICAgICAgICdub2RlX21vZHVsZXMvJyxcbiAgICAgICAgJ3Rlc3RzLycsXG4gICAgICAgICdkaXN0LycsXG4gICAgICAgICdzZXJ2ZXIvJyxcbiAgICAgICAgJyouY29uZmlnLmpzJyxcbiAgICAgICAgJy5lc2xpbnRyYy5janMnXG4gICAgICBdLFxuICAgICAgdGhyZXNob2xkczoge1xuICAgICAgICBsaW5lczogODAsXG4gICAgICAgIGZ1bmN0aW9uczogODAsXG4gICAgICAgIGJyYW5jaGVzOiA3NSxcbiAgICAgICAgc3RhdGVtZW50czogODBcbiAgICAgIH1cbiAgICB9LFxuICAgIGluY2x1ZGU6IFsndGVzdHMvKiovKi50ZXN0LmpzJywgJ3Rlc3RzLyoqLyouc3BlYy5qcyddXG4gIH1cbn0pO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUEyWSxTQUFTLG9CQUFvQjtBQUN4YSxPQUFPLFNBQVM7QUFDaEIsU0FBUyxxQkFBcUI7QUFGME4sSUFBTSwyQ0FBMkM7QUFJelMsSUFBTyx3QkFBUSxhQUFhO0FBQUEsRUFDMUIsU0FBUyxDQUFDLElBQUksQ0FBQztBQUFBLEVBQ2YsU0FBUztBQUFBLElBQ1AsT0FBTztBQUFBLE1BQ0wsS0FBSyxjQUFjLElBQUksSUFBSSxTQUFTLHdDQUFlLENBQUM7QUFBQSxJQUN0RDtBQUFBLEVBQ0Y7QUFBQSxFQUNBLE1BQU07QUFBQSxJQUNKLFNBQVM7QUFBQSxJQUNULGFBQWE7QUFBQSxJQUNiLFlBQVksQ0FBQyxrQkFBa0I7QUFBQSxJQUMvQixVQUFVO0FBQUEsTUFDUixVQUFVO0FBQUEsTUFDVixVQUFVLENBQUMsUUFBUSxRQUFRLFFBQVEsTUFBTTtBQUFBLE1BQ3pDLFNBQVM7QUFBQSxRQUNQO0FBQUEsUUFDQTtBQUFBLFFBQ0E7QUFBQSxRQUNBO0FBQUEsUUFDQTtBQUFBLFFBQ0E7QUFBQSxNQUNGO0FBQUEsTUFDQSxZQUFZO0FBQUEsUUFDVixPQUFPO0FBQUEsUUFDUCxXQUFXO0FBQUEsUUFDWCxVQUFVO0FBQUEsUUFDVixZQUFZO0FBQUEsTUFDZDtBQUFBLElBQ0Y7QUFBQSxJQUNBLFNBQVMsQ0FBQyxzQkFBc0Isb0JBQW9CO0FBQUEsRUFDdEQ7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=