diff --git a/backend/app/data/__init__.py b/backend/app/data/__init__.py index 4461129..4515f68 100644 --- a/backend/app/data/__init__.py +++ b/backend/app/data/__init__.py @@ -4,7 +4,7 @@ import os import yaml from .utils import (get_category_directory, load_yaml_file, validate, save_yaml_file, update_yaml_file, get_file_created_date, - get_file_modified_date) + get_file_modified_date, test_regex_pattern) logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -129,3 +129,48 @@ def handle_item(category, name): except Exception as e: logger.exception("Unexpected error occurred") return jsonify({"error": "An unexpected error occurred"}), 500 + + +@bp.route('//test', methods=['POST']) +def run_tests(category): + logger.info(f"Received regex test request for category: {category}") + + if category != 'regex_pattern': + logger.warning(f"Rejected test request - invalid category: {category}") + return jsonify({"error": + "Testing only supported for regex patterns"}), 400 + + try: + data = request.get_json() + if not data: + logger.warning("Rejected test request - no JSON data provided") + return jsonify({"error": "No JSON data provided"}), 400 + + pattern = data.get('pattern') + tests = data.get('tests', []) + + logger.info(f"Processing test request - Pattern: {pattern}") + logger.info(f"Number of test cases: {len(tests)}") + + if not pattern: + logger.warning("Rejected test request - missing pattern") + return jsonify({"error": "Pattern is required"}), 400 + + if not tests: + logger.warning("Rejected test request - no test cases provided") + return jsonify({"error": + "At least one test case is required"}), 400 + + success, message, updated_tests = test_regex_pattern(pattern, tests) + logger.info(f"Test execution completed - Success: {success}") + + if not success: + logger.warning(f"Test execution failed - {message}") + return jsonify({"success": False, "message": message}), 400 + + return jsonify({"success": True, "tests": updated_tests}), 200 + + except Exception as e: + logger.warning(f"Unexpected error in test endpoint: {str(e)}", + exc_info=True) + return jsonify({"success": False, "message": str(e)}), 500 diff --git a/backend/app/data/utils.py b/backend/app/data/utils.py index e2c17c9..842ce19 100644 --- a/backend/app/data/utils.py +++ b/backend/app/data/utils.py @@ -2,9 +2,10 @@ import os import yaml import shutil import logging -from typing import Dict, Any, Tuple from datetime import datetime +from typing import Dict, List, Any, Tuple, Union import git +import regex logger = logging.getLogger(__name__) @@ -15,7 +16,7 @@ FORMAT_DIR = '/app/data/db/custom_formats' PROFILE_DIR = '/app/data/db/profiles' # Expected fields for each category -REGEX_FIELDS = ["name", "pattern", "flags"] +REGEX_FIELDS = ["name", "pattern", "description", "tags", "tests"] FORMAT_FIELDS = ["name", "format", "description"] PROFILE_FIELDS = [ "name", @@ -138,17 +139,29 @@ def update_yaml_file(file_path: str, data: Dict[str, Any], # First save the updated content to the current file save_yaml_file(file_path, data_to_save, category) - # Then use git mv for the rename + # Check if file is being tracked by git repo = git.Repo(REPO_PATH) - # Convert to relative paths for git rel_old_path = os.path.relpath(file_path, REPO_PATH) rel_new_path = os.path.relpath(new_file_path, REPO_PATH) try: - repo.git.mv(rel_old_path, rel_new_path) + # Check if file is tracked by git + tracked_files = repo.git.ls_files().splitlines() + is_tracked = rel_old_path in tracked_files + + if is_tracked: + # Use git mv for tracked files + repo.git.mv(rel_old_path, rel_new_path) + else: + # For untracked files, manually move + os.rename(file_path, new_file_path) + except git.GitCommandError as e: - logger.error(f"Git mv failed: {e}") - raise Exception("Failed to rename file using git mv") + logger.error(f"Git operation failed: {e}") + raise Exception("Failed to rename file") + except OSError as e: + logger.error(f"File operation failed: {e}") + raise Exception("Failed to rename file") else: # Normal update without rename @@ -163,3 +176,74 @@ def update_yaml_file(file_path: str, data: Dict[str, Any], except Exception as e: raise + + +def test_regex_pattern( + pattern: str, + tests: List[Dict[str, Any]]) -> Tuple[bool, str, List[Dict[str, Any]]]: + """ + Test a regex pattern against a list of test cases using PCRE2 compatible engine. + + Args: + pattern: The regex pattern to test + tests: List of test dictionaries with 'input', 'expected', 'id', and 'passes' fields + + Returns: + Tuple of (success, message, updated_tests) + """ + logger.info(f"Starting regex pattern test - Pattern: {pattern}") + + try: + # Try to compile the regex with PCRE2 compatibility + try: + compiled_pattern = regex.compile(pattern, regex.V1) + logger.info( + "Pattern compiled successfully with PCRE2 compatibility") + except regex.error as e: + logger.warning(f"Invalid regex pattern: {str(e)}") + return False, f"Invalid regex pattern: {str(e)}", tests + + current_time = datetime.now().isoformat() + logger.info(f"Processing {len(tests)} test cases") + + # Run each test + for test in tests: + test_id = test.get('id', 'unknown') + test_input = test.get('input', '') + expected = test.get('expected', False) + + logger.info( + f"Running test {test_id} - Input: {test_input}, Expected: {expected}" + ) + + try: + # Test if pattern matches input + matches = bool(compiled_pattern.search(test_input)) + # Update test result + test['passes'] = matches == expected + test['lastRun'] = current_time + + if test['passes']: + logger.info( + f"Test {test_id} passed - Match result: {matches}") + else: + logger.warning( + f"Test {test_id} failed - Expected {expected}, got {matches}" + ) + + except Exception as e: + logger.warning(f"Error running test {test_id}: {str(e)}") + test['passes'] = False + test['lastRun'] = current_time + + # Log overall results + passed_tests = sum(1 for test in tests if test.get('passes', False)) + logger.info( + f"Test execution complete - {passed_tests}/{len(tests)} tests passed" + ) + + return True, "", tests + except Exception as e: + logger.warning(f"Unexpected error in test_regex_pattern: {str(e)}", + exc_info=True) + return False, str(e), tests diff --git a/backend/app/git/status/incoming_changes.py b/backend/app/git/status/incoming_changes.py index d937df0..b38e335 100644 --- a/backend/app/git/status/incoming_changes.py +++ b/backend/app/git/status/incoming_changes.py @@ -386,27 +386,84 @@ def compare_upgrade_until(local_upgrade, remote_upgrade): return changes -def compare_generic(local_data, remote_data): - """Process changes for non-profile files""" - if local_data is None and remote_data is not None: +def process_generic(old_data, new_data): + if old_data is None and new_data is not None: return [{'key': 'File', 'change': 'added'}] - - if local_data is not None and remote_data is None: + if old_data is not None and new_data is None: return [{'key': 'File', 'change': 'deleted'}] changes = [] - all_keys = set(local_data.keys()).union(set(remote_data.keys())) + all_keys = set(old_data.keys()).union(set(new_data.keys())) for key in all_keys: - local_value = local_data.get(key) - remote_value = remote_data.get(key) + old_value = old_data.get(key) + new_value = new_data.get(key) - if local_value != remote_value: - changes.append({ - 'key': key.title(), # Capitalize generic keys - 'change': 'modified', - 'from': local_value, - 'to': remote_value - }) + if old_value != new_value: + if key == 'tests': + old_tests = {t['id']: t for t in old_value or []} + new_tests = {t['id']: t for t in new_value or []} + + # Handle deleted tests + for test_id in set(old_tests) - set(new_tests): + test = old_tests[test_id] + changes.append({ + 'key': 'Test', + 'change': 'deleted', + 'from': + f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})', + 'to': None + }) + + # Handle added tests + for test_id in set(new_tests) - set(old_tests): + test = new_tests[test_id] + changes.append({ + 'key': + 'Test', + 'change': + 'added', + 'from': + None, + 'to': + f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})' + }) + + # Handle modified tests + for test_id in set(old_tests) & set(new_tests): + if old_tests[test_id] != new_tests[test_id]: + old_test = old_tests[test_id] + new_test = new_tests[test_id] + + if old_test['input'] != new_test['input']: + changes.append({ + 'key': + f'Test #{test_id}', + 'change': + 'modified', + 'from': + f'Input: "{old_test["input"]}"', + 'to': + f'Input: "{new_test["input"]}"' + }) + + if old_test['expected'] != new_test['expected']: + changes.append({ + 'key': + f'Test #{test_id}', + 'change': + 'modified', + 'from': + f'Expected: {old_test["expected"]}', + 'to': + f'Expected: {new_test["expected"]}' + }) + else: + changes.append({ + 'key': key.title(), + 'change': 'modified', + 'from': old_value, + 'to': new_value + }) return changes diff --git a/backend/app/git/status/merge_conflicts.py b/backend/app/git/status/merge_conflicts.py index 4b0c476..29d5dbd 100644 --- a/backend/app/git/status/merge_conflicts.py +++ b/backend/app/git/status/merge_conflicts.py @@ -399,7 +399,6 @@ def compare_upgrade_until(ours_upgrade, theirs_upgrade): def compare_generic(ours_data, theirs_data): - """Compare generic files for conflicts""" conflicts = [] all_keys = set(ours_data.keys()).union(set(theirs_data.keys())) @@ -411,11 +410,63 @@ def compare_generic(ours_data, theirs_data): theirs_value = theirs_data.get(key) if ours_value != theirs_value: - conflicts.append({ - 'parameter': key.title(), - 'local_value': ours_value, - 'incoming_value': theirs_value - }) + if key == 'tests': + ours_tests = {t['id']: t for t in ours_value or []} + theirs_tests = {t['id']: t for t in theirs_value or []} + + # Handle deleted tests + for test_id in set(ours_tests) - set(theirs_tests): + conflicts.append({ + 'parameter': f'Test {test_id}', + 'local_value': { + 'input': ours_tests[test_id]['input'], + 'expected': ours_tests[test_id]['expected'] + }, + 'incoming_value': None + }) + + # Handle added tests + for test_id in set(theirs_tests) - set(ours_tests): + conflicts.append({ + 'parameter': f'Test {test_id}', + 'local_value': None, + 'incoming_value': { + 'input': theirs_tests[test_id]['input'], + 'expected': theirs_tests[test_id]['expected'] + } + }) + + # Handle modified tests + for test_id in set(ours_tests) & set(theirs_tests): + if ours_tests[test_id] != theirs_tests[test_id]: + ours_test = ours_tests[test_id] + theirs_test = theirs_tests[test_id] + + if ours_test['input'] != theirs_test['input']: + conflicts.append({ + 'parameter': + f'Test {test_id} Input', + 'local_value': + ours_test['input'], + 'incoming_value': + theirs_test['input'] + }) + + if ours_test['expected'] != theirs_test['expected']: + conflicts.append({ + 'parameter': + f'Test {test_id} Expected', + 'local_value': + ours_test['expected'], + 'incoming_value': + theirs_test['expected'] + }) + else: + conflicts.append({ + 'parameter': key.title(), + 'local_value': ours_value, + 'incoming_value': theirs_value + }) return conflicts diff --git a/backend/app/git/status/outgoing_changes.py b/backend/app/git/status/outgoing_changes.py index 82c1955..0e1678a 100644 --- a/backend/app/git/status/outgoing_changes.py +++ b/backend/app/git/status/outgoing_changes.py @@ -418,10 +418,8 @@ def compare_upgrade_until(old_upgrade, new_upgrade): def process_generic(old_data, new_data): - """Process changes for non-profile files""" if old_data is None and new_data is not None: return [{'key': 'File', 'change': 'added'}] - if old_data is not None and new_data is None: return [{'key': 'File', 'change': 'deleted'}] @@ -433,11 +431,70 @@ def process_generic(old_data, new_data): new_value = new_data.get(key) if old_value != new_value: - changes.append({ - 'key': key.title(), # Capitalize generic keys - 'change': 'modified', - 'from': old_value, - 'to': new_value - }) + if key == 'tests': + old_tests = {t['id']: t for t in old_value or []} + new_tests = {t['id']: t for t in new_value or []} + + # Handle deleted tests + for test_id in set(old_tests) - set(new_tests): + test = old_tests[test_id] + changes.append({ + 'key': 'Test', + 'change': 'deleted', + 'from': + f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})', + 'to': None + }) + + # Handle added tests + for test_id in set(new_tests) - set(old_tests): + test = new_tests[test_id] + changes.append({ + 'key': + 'Test', + 'change': + 'added', + 'from': + None, + 'to': + f'#{test_id}: "{test["input"]}" (Expected: {test["expected"]})' + }) + + # Handle modified tests + for test_id in set(old_tests) & set(new_tests): + if old_tests[test_id] != new_tests[test_id]: + old_test = old_tests[test_id] + new_test = new_tests[test_id] + + if old_test['input'] != new_test['input']: + changes.append({ + 'key': + f'Test #{test_id}', + 'change': + 'modified', + 'from': + f'Input: "{old_test["input"]}"', + 'to': + f'Input: "{new_test["input"]}"' + }) + + if old_test['expected'] != new_test['expected']: + changes.append({ + 'key': + f'Test #{test_id}', + 'change': + 'modified', + 'from': + f'Expected: {old_test["expected"]}', + 'to': + f'Expected: {new_test["expected"]}' + }) + else: + changes.append({ + 'key': key.title(), + 'change': 'modified', + 'from': old_value, + 'to': new_value + }) return changes diff --git a/backend/requirements.txt b/backend/requirements.txt index 1555ae3..9e0fbd7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,4 +3,5 @@ Flask-CORS==3.0.10 PyYAML==5.4.1 requests==2.26.0 Werkzeug==2.0.1 -GitPython==3.1.24 \ No newline at end of file +GitPython==3.1.24 +regex==2023.10.3 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9abe0a7..61dc269 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,8 +19,10 @@ services: - backend_data:/app/data environment: - FLASK_ENV=development + - TZ=Australia/Adelaide # Add this line env_file: - .env.1 restart: always + volumes: backend_data: diff --git a/frontend/src/api/data.js b/frontend/src/api/data.js index 5ff6df6..03e289c 100644 --- a/frontend/src/api/data.js +++ b/frontend/src/api/data.js @@ -4,16 +4,9 @@ const BASE_URL = '/api/data'; const handleError = (error, operation) => { console.error(`Error ${operation}:`, error); - if (error.response?.data) { - return { - success: false, - message: error.response.data.error - }; - } - return { - success: false, - message: `Failed to ${operation}` - }; + const errorMessage = + error.response?.data?.error || `Failed to ${operation}`; + throw new Error(errorMessage); // Throw instead of returning an error object }; // Get all items for a category @@ -92,6 +85,22 @@ export const RegexPatterns = { getAll: () => getAllItems('regex_pattern'), get: name => getItem('regex_pattern', name), create: data => createItem('regex_pattern', data), - update: (name, data) => updateItem('regex_pattern', name, data), - delete: name => deleteItem('regex_pattern', name) + update: (name, data, newName) => + updateItem('regex_pattern', name, data, newName), + delete: name => deleteItem('regex_pattern', name), + // Add this new method + runTests: async (pattern, tests) => { + try { + const response = await axios.post( + `${BASE_URL}/regex_pattern/test`, + { + pattern, + tests + } + ); + return response.data; + } catch (error) { + return handleError(error, 'run tests'); + } + } }; diff --git a/frontend/src/components/regex/AddUnitTestModal.jsx b/frontend/src/components/regex/AddUnitTestModal.jsx new file mode 100644 index 0000000..4efe4cd --- /dev/null +++ b/frontend/src/components/regex/AddUnitTestModal.jsx @@ -0,0 +1,138 @@ +// AddUnitTestModal.jsx +import React, {useState, useEffect} from 'react'; +import PropTypes from 'prop-types'; +import Modal from '../ui/Modal'; + +const AddUnitTestModal = ({isOpen, onClose, onAdd, tests, editTest = null}) => { + const [input, setInput] = useState(''); + const [shouldMatch, setShouldMatch] = useState(true); + + // Reset form when opening modal, handling both new and edit cases + useEffect(() => { + if (isOpen) { + if (editTest) { + setInput(editTest.input); + setShouldMatch(editTest.expected); + } else { + setInput(''); + setShouldMatch(true); + } + } + }, [isOpen, editTest]); + + const handleSubmit = () => { + const getNextTestId = testArray => { + if (!testArray || testArray.length === 0) return 1; + return Math.max(...testArray.map(test => test.id)) + 1; + }; + + const testData = { + id: editTest ? editTest.id : getNextTestId(tests), + input, + expected: shouldMatch, + passes: false, + lastRun: null + }; + + onAdd(testData); + handleClose(); + }; + + const handleClose = () => { + setInput(''); + setShouldMatch(true); + onClose(); + }; + + return ( + + + + + }> + {/* Rest of the modal content remains the same */} +
+
+ + setInput(e.target.value)} + className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 + rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 + placeholder-gray-500 dark:placeholder-gray-400 + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent' + placeholder='Enter string to test against pattern...' + autoFocus + /> +
+ +
+ +
+ + +
+
+
+
+ ); +}; + +AddUnitTestModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, + tests: PropTypes.array.isRequired, + editTest: PropTypes.shape({ + id: PropTypes.number.isRequired, + input: PropTypes.string.isRequired, + expected: PropTypes.bool.isRequired, + passes: PropTypes.bool.isRequired, + lastRun: PropTypes.string + }) +}; + +export default AddUnitTestModal; diff --git a/frontend/src/components/regex/DeleteConfirmationModal.jsx b/frontend/src/components/regex/DeleteConfirmationModal.jsx new file mode 100644 index 0000000..0048905 --- /dev/null +++ b/frontend/src/components/regex/DeleteConfirmationModal.jsx @@ -0,0 +1,48 @@ +// DeleteConfirmationModal.jsx +import React from 'react'; +import PropTypes from 'prop-types'; +import Modal from '@ui/Modal'; +import {AlertTriangle} from 'lucide-react'; + +const DeleteConfirmationModal = ({isOpen, onClose, onConfirm}) => { + return ( + + + + + }> +
+ +

+ Are you sure you want to delete this test case? This action + cannot be undone. +

+
+
+ ); +}; + +DeleteConfirmationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired +}; + +export default DeleteConfirmationModal; diff --git a/frontend/src/components/regex/RegexCard.jsx b/frontend/src/components/regex/RegexCard.jsx index 0c54a2e..47cdf3c 100644 --- a/frontend/src/components/regex/RegexCard.jsx +++ b/frontend/src/components/regex/RegexCard.jsx @@ -1,71 +1,125 @@ +import React from 'react'; import PropTypes from 'prop-types'; +import {Copy} from 'lucide-react'; -function unsanitize(text) { - return text.replace(/\\:/g, ':').replace(/\\n/g, '\n'); -} +const RegexCard = ({pattern, onEdit, onClone, formatDate, sortBy}) => { + const totalTests = pattern.tests.length; + const passedTests = pattern.tests.filter(t => t.passes).length; + const passRate = Math.round((passedTests / totalTests) * 100); -function RegexCard({ regex, onEdit, onClone, showDate, formatDate }) { - return ( -
onEdit(regex)} - > -
-

- {unsanitize(regex.name)} -

- -
-
-
-          {regex.pattern}
-        
-
-

- {unsanitize(regex.description)} -

- {showDate && ( -

- Modified: {formatDate(regex.date_modified)} -

- )} -
- {regex.tags && regex.tags.map(tag => ( - - {tag} - - ))} -
-
- ); -} + return ( +
onEdit(pattern)}> +
+ {/* Header Section */} +
+

+ {pattern.name} +

+ +
+ + {/* Pattern Display with line clamp */} +
+ + {pattern.pattern} + +
+ + {/* Description if exists - with line clamp */} + {pattern.description && ( +

+ {pattern.description} +

+ )} + + {/* Bottom Metadata */} +
+ {/* Test Results */} +
+ {totalTests > 0 ? ( + <> + = 80 + ? 'text-yellow-600 dark:text-yellow-400' + : 'text-red-600 dark:text-red-400' + }`}> + {passRate}% Pass Rate + + + ({passedTests}/{totalTests} tests) + + + ) : ( + + No tests + + )} +
+ + {/* Tags */} + {pattern.tags && pattern.tags.length > 0 && ( +
+ {pattern.tags.map(tag => ( + + {tag} + + ))} +
+ )} + + {/* Date Modified/Created */} + {(sortBy === 'dateModified' || + sortBy === 'dateCreated') && ( + + {sortBy === 'dateModified' ? 'Modified' : 'Created'} + :{' '} + {formatDate( + sortBy === 'dateModified' + ? pattern.modified_date + : pattern.created_date + )} + + )} +
+
+
+ ); +}; RegexCard.propTypes = { - regex: PropTypes.shape({ - id: PropTypes.number.isRequired, - name: PropTypes.string.isRequired, - pattern: PropTypes.string.isRequired, - description: PropTypes.string, - date_modified: PropTypes.string.isRequired, - tags: PropTypes.arrayOf(PropTypes.string), - }).isRequired, - onEdit: PropTypes.func.isRequired, - onClone: PropTypes.func.isRequired, // Added clone handler prop - showDate: PropTypes.bool.isRequired, - formatDate: PropTypes.func.isRequired, + pattern: PropTypes.shape({ + name: PropTypes.string.isRequired, + pattern: PropTypes.string.isRequired, + description: PropTypes.string, + tags: PropTypes.arrayOf(PropTypes.string), + tests: PropTypes.arrayOf( + PropTypes.shape({ + input: PropTypes.string.isRequired, + expected: PropTypes.bool.isRequired, + passes: PropTypes.bool.isRequired + }) + ).isRequired, + created_date: PropTypes.string.isRequired, + modified_date: PropTypes.string.isRequired + }).isRequired, + onEdit: PropTypes.func.isRequired, + onClone: PropTypes.func.isRequired, + formatDate: PropTypes.func.isRequired, + sortBy: PropTypes.string.isRequired }; export default RegexCard; diff --git a/frontend/src/components/regex/RegexGeneralTab.jsx b/frontend/src/components/regex/RegexGeneralTab.jsx new file mode 100644 index 0000000..4041220 --- /dev/null +++ b/frontend/src/components/regex/RegexGeneralTab.jsx @@ -0,0 +1,194 @@ +import React, {useState} from 'react'; +import PropTypes from 'prop-types'; +import TextArea from '../ui/TextArea'; +import {InfoIcon} from 'lucide-react'; + +const RegexGeneralTab = ({ + name, + description, + pattern, + tags, + onNameChange, + onDescriptionChange, + onPatternChange, + onAddTag, + onRemoveTag, + error, + patternError +}) => { + const [newTag, setNewTag] = useState(''); + + const handleAddTag = () => { + if (newTag.trim() && !tags.includes(newTag.trim())) { + onAddTag(newTag.trim()); + setNewTag(''); + } + }; + + const handleKeyPress = e => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + return ( +
+ {error && ( +
+

+ {error} +

+
+ )} +
+ {/* Name Input */} +
+ + onNameChange(e.target.value)} + placeholder='Enter pattern name' + className='w-full rounded-md border border-gray-300 dark:border-gray-600 + bg-white dark:bg-gray-700 px-3 py-2 text-sm + text-gray-900 dark:text-gray-100 + placeholder-gray-500 dark:placeholder-gray-400 + focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent + transition-colors duration-200' + /> +
+ + {/* Description */} +
+ +