From 12ea08a7e14f7f9e9ed285ce7f048f9fc57d2365 Mon Sep 17 00:00:00 2001 From: FragginWagon Date: Thu, 29 Jan 2026 03:28:06 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20unit=20tests=20for=20useLineS?= =?UTF-8?q?election=20composable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/composables/useLineSelection.js | 209 ++++++++++++ .../unit/composables/useLineSelection.test.js | 310 ++++++++++++++++++ ....timestamp-1769657286949-a81d2be184c45.mjs | 41 +++ 3 files changed, 560 insertions(+) create mode 100644 code/websites/pokedex.online/src/composables/useLineSelection.js create mode 100644 code/websites/pokedex.online/tests/unit/composables/useLineSelection.test.js create mode 100644 code/websites/pokedex.online/vitest.config.js.timestamp-1769657286949-a81d2be184c45.mjs diff --git a/code/websites/pokedex.online/src/composables/useLineSelection.js b/code/websites/pokedex.online/src/composables/useLineSelection.js new file mode 100644 index 0000000..5d14c93 --- /dev/null +++ b/code/websites/pokedex.online/src/composables/useLineSelection.js @@ -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} displayLines - Current displayed lines with metadata + * @param {Ref} fileContent - Full file content for export + * @param {Ref} 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 + }; +} diff --git a/code/websites/pokedex.online/tests/unit/composables/useLineSelection.test.js b/code/websites/pokedex.online/tests/unit/composables/useLineSelection.test.js new file mode 100644 index 0000000..3041cc2 --- /dev/null +++ b/code/websites/pokedex.online/tests/unit/composables/useLineSelection.test.js @@ -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 || ''); + }); + }); +}); diff --git a/code/websites/pokedex.online/vitest.config.js.timestamp-1769657286949-a81d2be184c45.mjs b/code/websites/pokedex.online/vitest.config.js.timestamp-1769657286949-a81d2be184c45.mjs new file mode 100644 index 0000000..60e8177 --- /dev/null +++ b/code/websites/pokedex.online/vitest.config.js.timestamp-1769657286949-a81d2be184c45.mjs @@ -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=