diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 02f9762..a0c31b3 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,25 +1,245 @@ -language: "en" +--- +# yaml-language-server: $schema=https://docs.coderabbit.ai/schema/schema.v2.json + +# CodeRabbit Configuration +# StringManipulation PHP 8.3+ Library +# Enforces strict quality standards: PHPStan MAX, SOLID principles, PER-CS2.0 + +# Language and Tone +language: en-ZA early_access: true -tone_instructions: "You're an expert PHP reviewer, proficient in PER Coding Style 2.0 (extending PSR-12 & PSR-1), SOLID, and FOOP. Advise on immutable data, pure functions, and functional composition while ensuring robust OOP. Provide concise, actionable feedback." +tone_instructions: >- + Expert PHP reviewer proficient in PER Coding Style 2.0, SOLID, and FOOP. + Advise on immutable data, pure functions, and functional composition. + Provide concise, actionable feedback. + +# Review configuration reviews: - request_changes_workflow: true - high_level_summary: true - poem: false - review_status: true - collapse_walkthrough: true - auto_review: - enabled: true - ignore_title_keywords: - - "WIP" - - "DO NOT MERGE" - drafts: false - base_branches: - - "develop" - - "feat/.*" - - "main" - path_instructions: - - path: "**/*.php" - instructions: | - Review PHP code for adherence to PER Coding Style 2.0 guidelines. Ensure proper namespace usage, code organisation, and separation of concerns. Verify that SOLID principles are followed and encourage FOOP techniques—such as employing immutable data, pure functions, and functional composition—to improve maintainability, testability, and performance. + # Review profile and behaviour + profile: "assertive" + request_changes_workflow: true + high_level_summary: true + high_level_summary_placeholder: "@coderabbitai summary" + poem: false + review_status: true + commit_status: true + fail_commit_status: false + collapse_walkthrough: true + + # Walkthrough features + changed_files_summary: true + sequence_diagrams: true + estimate_code_review_effort: true + assess_linked_issues: true + related_issues: true + related_prs: true + suggested_labels: true + auto_apply_labels: false + suggested_reviewers: true + auto_assign_reviewers: false + + # Path filters (exclude generated files, vendor, etc.) + path_filters: + - "!vendor/**" + - "!reports/**" + - "!node_modules/**" + - "!*.lock" + - "src/**" + - "tests/**" + + # Path-specific instructions + path_instructions: + - path: "**/*.php" + instructions: | + Review PHP code for adherence to PER Coding Style 2.0 guidelines. + Ensure proper namespace usage, code organisation, and separation + of concerns. Verify that SOLID principles are followed and + encourage FOOP techniques—such as immutable data, pure functions, + and functional composition—to improve maintainability, + testability, and performance. + + Specific checks: + - Strict typing: `declare(strict_types=1);` is required + - Explicit type declarations for all parameters and return types + - Final classes with static methods where appropriate + - Comprehensive docblocks with @param, @return, and @example tags + - No methods exceeding 100 lines (PHPMD rule) + - PHP 8.3+ features and patterns + - Proper error handling and null safety + + - path: "tests/**/*.php" + instructions: | + Review test code for: + - TDD compliance (tests should be clear and comprehensive) + - PHPUnit best practices + - 100% coverage for critical paths, 90%+ for standard code + - Fast execution (unit tests <100ms, integration <5s) + - Independent, deterministic tests + - Descriptive test names and clear assertions + - Proper mocking and test isolation + + # Automatic review settings + auto_review: + enabled: true + auto_incremental_review: true + ignore_title_keywords: + - "WIP" + - "DO NOT MERGE" + - "DRAFT" + drafts: false + base_branches: + - "main" + - "develop" + - "feat/.*" + - "fix/.*" + - "refactor/.*" + + # Finishing touches + finishing_touches: + docstrings: + enabled: true + unit_tests: + enabled: true + + # Pre-merge checks + pre_merge_checks: + docstrings: + mode: "error" + threshold: 80 + + title: + mode: "warning" + requirements: "Follow conventional commits format: type(scope): description. Types: feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|security|deps|config|release" + + description: + mode: "warning" + + issue_assessment: + mode: "warning" + + custom_checks: + - mode: "error" + name: "Strict Type Declarations" + instructions: "All PHP files in src/ must begin with `declare(strict_types=1);` after the opening PHP tag and before the namespace declaration." + + - mode: "error" + name: "No Debugging Functions" + instructions: "Production code (src/ directory) must not contain debugging functions: var_dump(), var_export(), print_r(), dd(), or dump()." + + - mode: "warning" + name: "Test Coverage" + instructions: "New code must have corresponding PHPUnit tests. Critical features require 100% line coverage; standard features require 90% coverage." + + - mode: "warning" + name: "Performance Complexity" + instructions: "Algorithms should avoid O(n²) or worse time complexity. Document Big O time complexity for non-trivial algorithms." + + - mode: "warning" + name: "Security Best Practices" + instructions: "Validate all user input. Use parameterised statements for SQL queries. Never hardcode secrets, credentials, or API keys." + + # Tools configuration + tools: + # GitHub Checks - MAX TIMEOUT (15 minutes) + github-checks: + enabled: true + timeout_ms: 900000 + + # PHP Tools + phpstan: + enabled: true + level: "max" + + phpmd: + enabled: true + + phpcs: + enabled: true + + # Security and quality tools + gitleaks: + enabled: true + + actionlint: + enabled: true + + semgrep: + enabled: true + + # Documentation and style + languagetool: + enabled: true + level: "default" + enabled_only: false + + # Disable non-PHP tools to reduce noise + shellcheck: + enabled: false + + ruff: + enabled: false + + eslint: + enabled: false + + yamllint: + enabled: true + +# Chat configuration chat: - auto_reply: true + auto_reply: true + art: true + +# Knowledge base +knowledge_base: + opt_out: false + + web_search: + enabled: true + + code_guidelines: + enabled: true + filePatterns: + - "**/CLAUDE.md" + - "**/.cursorrules" + - ".github/copilot-instructions.md" + - "docs/coding-standards.md" + + learnings: + scope: "auto" + + issues: + scope: "local" + + pull_requests: + scope: "local" + +# Code Generation Configuration +code_generation: + # Docstring Generation + docstrings: + language: en-ZA + path_instructions: + - path: "src/**/*.php" + instructions: | + Focus on explaining business behaviour and functionality, not obvious implementation. + Use South African English spelling (organisation, optimisation, analyse, behaviour). + Include @param and @return tags with proper types. + Add @throws for exceptions. + Add @example tags demonstrating usage. + Reference related documentation with @see tags. + Follow PER Coding Style 2.0 conventions. + + # Unit Test Generation + unit_tests: + path_instructions: + - path: "src/**/*.php" + instructions: | + Generate PHPUnit tests following TDD principles: + - Test names must describe behaviour: test_methodName_condition_expectedBehaviour + - Each test must follow the AAA pattern (Arrange-Act-Assert) + - Include unit tests for all public methods, edge cases, and error conditions + - Tests must be independent and deterministic + - Target fast execution (<100ms for unit tests) + - Use data providers with #[DataProvider] for testing multiple scenarios + - Focus on critical functionality and edge cases diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..54c1cae --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.{yml,yaml,json}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.github/workflows/codecov.yml b/.github/workflows/codecov.yml index e892b38..ca27eed 100644 --- a/.github/workflows/codecov.yml +++ b/.github/workflows/codecov.yml @@ -19,7 +19,7 @@ jobs: run: composer self-update && composer install && composer dump-autoload - name: Run tests and collect coverage - run: vendor/bin/phpunit + run: vendor/bin/pest --coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v4.0.1 diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index a0611c5..e899ffc 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -57,9 +57,9 @@ jobs: # This step sets up Go environment for the job. - name: Set up Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.23" # This step installs osv-scanner for vulnerability scanning. - name: Install osv-scanner @@ -93,16 +93,16 @@ jobs: if: steps.code-style.outcome == 'success' run: composer test:phpmd - # This step runs tests with PHPUnit. - - name: Run tests with PHPUnit - id: phpunit + # This step runs tests with Pest. + - name: Run tests with Pest + id: pest if: steps.phpmd.outcome == 'success' - run: composer test:phpunit + run: composer test:pest # This step runs mutation testing with Infection. - name: Run Mutation Testing id: infection - if: steps.phpunit.outcome == 'success' + if: steps.pest.outcome == 'success' run: composer test:infection # This step runs static analysis with PHPStan. @@ -197,7 +197,7 @@ jobs: - name: Create release id: create_release if: github.ref == 'refs/heads/main' && steps.get-commits.outcome == 'success' - uses: actions/create-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.gitignore b/.gitignore index 91e8a31..0dc923e 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ !*/ # ======================================== -# CORE APPLICATION FILES +# CORE APPLICATION FILES # ======================================== !*.php !composer.json @@ -57,7 +57,7 @@ !.codacy.yaml # ======================================== -# DOCKER & INFRASTRUCTURE +# DOCKER & INFRASTRUCTURE # ======================================== !Dockerfile !docker-compose.yml @@ -81,7 +81,7 @@ !.pr_agent.toml !sweep.yaml -# ======================================== +# ======================================== # GIT CONFIGURATION # ======================================== !.gitignore @@ -102,6 +102,10 @@ package-lock.json .phpunit.cache .phpunit.result.cache .php-cs-fixer.cache +.phpstan/ + +# Temporary files +commit_messages.txt *.tmp # Build artifacts and reports diff --git a/.phan/config.php b/.phan/config.php index c54ef1b..752d882 100644 --- a/.phan/config.php +++ b/.phan/config.php @@ -359,7 +359,6 @@ // your application should be included in this list. 'directory_list' => [ 'src', - 'tests', 'vendor', ], diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 547b38d..73d8003 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,9 +38,9 @@ repos: files: \.php$ pass_filenames: false - - id: phpunit - name: PHPUnit Tests - entry: docker-compose run --rm test-phpunit + - id: pest + name: Pest Tests + entry: docker-compose run --rm tests ./vendor/bin/pest --no-coverage language: system files: \.(php|xml)$ pass_filenames: false @@ -73,9 +73,25 @@ repos: files: \.php$ pass_filenames: false - - id: infection - name: Infection Mutation Testing - entry: docker-compose run --rm test-infection + - id: test-coverage + name: Pest Test Coverage + entry: docker-compose run --rm tests ./vendor/bin/pest --coverage --min=100 + language: system + files: \.php$ + pass_filenames: false + stages: [pre-push] + + - id: type-coverage + name: Pest Type Coverage + entry: docker-compose run --rm tests ./vendor/bin/pest --type-coverage --min=100 + language: system + files: \.php$ + pass_filenames: false + stages: [pre-push] + + - id: mutation + name: Pest Mutation Testing + entry: docker-compose run --rm tests ./vendor/bin/pest --mutate --min=86 --everything --covered-only language: system files: \.php$ pass_filenames: false @@ -83,7 +99,7 @@ repos: # Standard pre-commit hooks - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v5.0.0 hooks: - id: trailing-whitespace exclude: ^vendor/ @@ -96,3 +112,7 @@ repos: - id: check-xml - id: mixed-line-ending args: ['--fix=lf'] + - id: check-merge-conflict + - id: check-case-conflict + - id: detect-private-key + - id: fix-byte-order-marker diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..15fc82c --- /dev/null +++ b/.yamllint @@ -0,0 +1,14 @@ +--- +extends: default + +rules: + line-length: + max: 200 + level: warning + comments: + min-spaces-from-content: 1 + indentation: + spaces: 2 + indent-sequences: true + truthy: + allowed-values: ['true', 'false'] diff --git a/CLAUDE.md b/CLAUDE.md index 5093c0c..9083f9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,8 +13,8 @@ **IMPORTANT**: Always use Docker for testing to ensure consistent environment with PHP 8.3 and AST extension. - Run all tests: `docker-compose run --rm test-all` -- Run PHPUnit tests: `docker-compose run --rm test-phpunit` -- Run single test: `docker-compose run --rm app ./vendor/bin/phpunit --filter testClassName` +- Run Pest tests: `docker-compose run --rm tests ./vendor/bin/pest` +- Run single test: `docker-compose run --rm tests ./vendor/bin/pest --filter testName` - Code style check: `docker-compose run --rm test-code-style` - Static analysis: - PHPStan: `docker-compose run --rm test-phpstan` @@ -28,12 +28,20 @@ ### Local (Requires PHP 8.3+ with AST extension) - Run all tests: `composer tests` -- Run single test: `./vendor/bin/phpunit --filter testClassName` or `./vendor/bin/phpunit --filter '/::testMethodName$/'` +- Run Pest tests: `./vendor/bin/pest` +- Run single test: `./vendor/bin/pest --filter testName` - Code style check: `composer test:code-style` - Static analysis: `composer test:phpstan`, `composer test:psalm`, `composer test:phan` - Linting: `composer test:lint` - Mess detection: `composer test:phpmd` +### Code Review +- CodeRabbit review: `coderabbit review --type committed --config .coderabbit.yaml --plain --base main` + - Reviews committed changes against main branch + - Uses project-specific configuration from .coderabbit.yaml + - Plain text output for terminal display + - Note: Can timeout if simout set too low; use 30 minute timeout + ## Code Style Guidelines - PHP version: >=8.3.0 - Strict typing required: `declare(strict_types=1);` @@ -46,5 +54,5 @@ - Null handling: Explicit checks, optional parameters default to empty string - Documentation: 100% method coverage with examples - Standards: PSR guidelines with Laravel Pint (preset "per") -- Testing: PHPUnit with complete coverage +- Testing: Pest PHP with complete coverage - PHPMD: Methods must not exceed 100 lines diff --git a/composer.json b/composer.json index d0ab2a0..c823bca 100644 --- a/composer.json +++ b/composer.json @@ -37,27 +37,32 @@ "sort-packages": true, "allow-plugins": { "infection/extension-installer": true, - "phpstan/extension-installer": true + "phpstan/extension-installer": true, + "pestphp/pest-plugin": true } }, "minimum-stability": "stable", "require": { - "php": ">=8.3.0|>=8.4.0" + "php": "^8.3" }, "require-dev": { "enlightn/security-checker": ">=2.0", "infection/infection": ">=0.31.2", "laravel/pint": ">=1.24.0", + "pestphp/pest": "^4.1", + "pestphp/pest-plugin-drift": "^4.0", + "pestphp/pest-plugin-type-coverage": "^4.0", "phan/phan": ">=5.5.1", "php-parallel-lint/php-parallel-lint": ">=1.4.0", "phpmd/phpmd": ">=2.15", "phpstan/extension-installer": ">=1.4.3", "phpstan/phpstan": ">=2.1.22", "phpstan/phpstan-strict-rules": ">=2.0.6", - "phpunit/phpunit": ">=11.0.9|>=12.0.2", "psalm/plugin-phpunit": ">=0.19.3", "rector/rector": ">=2.1.4", + "rector/type-perfect": "^2.1", "roave/security-advisories": "dev-latest", + "tomasvotruba/type-coverage": "^2.0", "vimeo/psalm": ">=6.7" }, "scripts-descriptions": { @@ -68,10 +73,13 @@ "test:phan": "Execute Phan static analysis for type safety, dead code detection, and PHP compatibility validation", "test:phpmd": "Analyse code complexity, design patterns, and identify potential bugs using PHP Mess Detector rules", "test:phpstan": "Perform advanced static analysis with PHPStan for type checking, null safety, and logic validation", - "test:phpunit": "Run comprehensive PHPUnit test suite (166 tests) with strict type checking and edge case coverage", + "test:pest": "Run comprehensive Pest test suite with strict type checking and edge case coverage", "test:psalm": "Execute Psalm static analysis for advanced type inference, purity checking, and security validation", "test:rector": "Analyse code for modernisation opportunities and PHP 8.3+ feature adoption using Rector rules", - "test:vulnerabilities-check": "Scan all dependencies for known CVE vulnerabilities and security advisories using Enlightn Security Checker" + "test:vulnerabilities-check": "Scan all dependencies for known CVE vulnerabilities and security advisories using Enlightn Security Checker", + "fix:code-style": "Automatically fix code style issues with Pint", + "fix:rector": "Apply Rector refactorings to improve code quality", + "analyse:all": "Run all static analysis tools (PHPStan, Psalm, Phan)" }, "scripts": { "post-update-cmd": [ @@ -85,7 +93,7 @@ "@test:lint", "@test:code-style", "@test:phpmd", - "@test:phpunit", + "@test:pest", "@test:infection", "@test:phpstan", "@test:phan", @@ -99,9 +107,16 @@ "test:phan": "phan --no-progress-bar", "test:phpmd": "phpmd src,tests text phpmd.xml", "test:phpstan": "phpstan analyse --memory-limit=-1 --no-progress --no-interaction", - "test:phpunit": "phpunit --no-coverage --no-logging", + "test:pest": "pest --no-coverage", "test:psalm": "psalm --no-cache --no-progress --show-info=false", "test:rector": "rector --dry-run", - "test:vulnerabilities-check": "security-checker security:check" + "test:vulnerabilities-check": "security-checker security:check", + "fix:code-style": "pint", + "fix:rector": "rector", + "analyse:all": [ + "@test:phpstan", + "@test:psalm", + "@test:phan" + ] } } diff --git a/infection.json.dist b/infection.json.dist new file mode 100644 index 0000000..4f98ccc --- /dev/null +++ b/infection.json.dist @@ -0,0 +1,38 @@ +{ + "$schema": "vendor/infection/infection/resources/schema.json", + "source": { + "directories": [ + "src" + ] + }, + "logs": { + "text": "infection.log", + "summary": "infection-summary.log", + "json": "infection.json", + "perMutator": "infection-per-mutator.md" + }, + "tmpDir": ".infection", + "timeout": 10, + "php": { + "configTimeout": 30 + }, + "mutators": { + "@default": true, + "@function_signature": true, + "@number": true, + "@operator": true, + "@regex": true, + "@removal": true, + "@return_value": true, + "@sort": true, + "@zero_iteration": true, + "@cast": true + }, + "testFramework": "phpunit", + "testFrameworkOptions": "--no-coverage --no-logging", + "bootstrap": "vendor/autoload.php", + "minMsi": 85, + "minCoveredMsi": 90, + "threads": 4, + "ignoreMsiWithNoMutations": true +} diff --git a/phpmd.xml b/phpmd.xml index ef1f8cc..3f48534 100644 --- a/phpmd.xml +++ b/phpmd.xml @@ -1,17 +1,56 @@ - - This ruleset defines the custom checks and standards for my PHP codebase. + Custom PHPMD rules for StringManipulation library focusing on maintainability, + code quality, and adherence to SOLID principles. + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon index a53d0cc..72fcab1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,10 +1,35 @@ parameters: level: max + paths: + - src/ + tmpDir: .phpstan phpVersion: 80300 treatPhpDocTypesAsCertain: false tipsOfTheDay: false reportWrongPhpDocTypeInVarTag: true - paths: - - src - - tests + checkBenevolentUnionTypes: true + checkUninitializedProperties: true + checkTooWideReturnTypesInProtectedAndPublicMethods: true + checkImplicitMixed: true + reportAnyTypeWideningInVarTag: true + reportPossiblyNonexistentConstantArrayOffset: true + reportPossiblyNonexistentGeneralArrayOffset: true + + type_perfect: + null_over_false: true # Use null instead of false for "no-result" + no_mixed_property: true # Prevent properties from having mixed types + no_mixed_caller: true # Prevent method calls on mixed types + + type_coverage: + return: 95 # Require 95% return type coverage + param: 99 # Require 99% parameter type coverage + property: 95 # Require 95% property type coverage + constant: 85 # Require 85% constant type coverage (PHP 8.3+) + declare: 100 # Require 100% strict_types declarations + + parallel: + jobSize: 20 + maximumNumberOfProcesses: 32 + minimumNumberOfJobsPerProcess: 2 + ignoreErrors: diff --git a/phpunit.xml b/phpunit.xml index 30d08e3..0bb1a70 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,15 +19,6 @@ src - - - - - - - - - diff --git a/psalm.xml b/psalm.xml index 2678feb..8323959 100644 --- a/psalm.xml +++ b/psalm.xml @@ -7,10 +7,23 @@ findUnusedBaselineEntry="true" findUnusedCode="true" taintAnalysis="true" + strictBinaryOperands="true" + ensureArrayStringOffsetsExist="true" + ensureArrayIntOffsetsExist="true" + reportMixedIssues="true" + totallyTyped="true" + sealAllMethods="true" + sealAllProperties="true" + memoizeMethodCallResults="true" + hoistConstants="true" + addParamTypes="true" + checkForThrowsDocblock="true" + checkForThrowsInGlobalScope="true" + ignoreInternalFunctionFalseReturn="false" + ignoreInternalFunctionNullReturn="false" > - @@ -20,5 +33,16 @@ + + + + + + + + + + + diff --git a/rector.php b/rector.php index 6de2d4d..94cafe8 100644 --- a/rector.php +++ b/rector.php @@ -1,75 +1,35 @@ bootstrapFiles([__DIR__ . '/vendor/autoload.php']); - - $rectorConfig->paths([__DIR__ . '/src', __DIR__ . '/tests']); - - $rectorConfig->skip([__DIR__ . '/bootstrap/cache']); - - // register a single rule - $rectorConfig->rule(InlineConstructorDefaultToPropertyRector::class); - $rectorConfig->rule(RenameForeachValueVariableToMatchExprVariableRector::class); - $rectorConfig->rule(RenameForeachValueVariableToMatchMethodCallReturnTypeRector::class); - $rectorConfig->rule(TypedPropertyFromStrictConstructorRector::class); - $rectorConfig->rule(NullableCompareToNullRector::class); - $rectorConfig->rule(EncapsedStringsToSprintfRector::class); - $rectorConfig->rule(NewlineAfterStatementRector::class); - $rectorConfig->rule(NewlineBeforeNewAssignSetRector::class); - $rectorConfig->rule(PostIncDecToPreIncDecRector::class); - $rectorConfig->rule(SeparateMultiUseImportsRector::class); - $rectorConfig->rule(SplitDoubleAssignRector::class); - - $rectorConfig->phpVersion(PhpVersion::PHP_83); - - // define sets of rules - $rectorConfig->sets( - [ - LevelSetList::UP_TO_PHP_83, - SetList::CODE_QUALITY, - SetList::CODING_STYLE, - SetList::DEAD_CODE, - SetList::EARLY_RETURN, - SetList::PHP_83, - SetList::TYPE_DECLARATION, - SetList::NAMING, - SetList::PRIVATIZATION, - SetList::STRICT_BOOLEANS, - SetList::INSTANCEOF, - ], - ); - - $rectorConfig->importNames(); - $rectorConfig->importShortClasses(false); - - $rectorConfig->skip( - [ - FlipTypeControlToUseExclusiveTypeRector::class, - RemoveConcatAutocastRector::class, // Skip to avoid conflict with Psalm strict mode - ], - ); -}; +return RectorConfig::configure() + ->withPaths([__DIR__ . '/src', __DIR__ . '/tests']) + ->withRules([ + InlineConstructorDefaultToPropertyRector::class, + DeclareStrictTypesRector::class, + TypedPropertyFromAssignsRector::class, + AddVoidReturnTypeWhereNoReturnRector::class, + ]) + ->withPhpVersion(PhpVersion::PHP_83) + ->withSets([ + LevelSetList::UP_TO_PHP_83, + SetList::CODING_STYLE, + SetList::EARLY_RETURN, + SetList::INSTANCEOF, + SetList::PRIVATIZATION, + SetList::NAMING, + PHPUnitSetList::ANNOTATIONS_TO_ATTRIBUTES, + ]) + ->withTypeCoverageLevel(10) + ->withDeadCodeLevel(10) + ->withCodeQualityLevel(10); diff --git a/src/StringManipulation.php b/src/StringManipulation.php index 1325076..2049154 100644 --- a/src/StringManipulation.php +++ b/src/StringManipulation.php @@ -41,7 +41,7 @@ final class StringManipulation * * @var array */ - private static array $ACCENTS_REPLACEMENT = []; + private static array $accentsReplacement = []; /** * Static property to cache combined transformation mapping for searchWords() optimization. @@ -50,7 +50,7 @@ final class StringManipulation * * @var array */ - private static array $SEARCH_WORDS_MAPPING = []; + private static array $searchWordsMapping = []; /** @@ -72,6 +72,8 @@ final class StringManipulation * * @return null|string The transformed string suitable for database search, or null if input was null. * + * @throws LogicException If REMOVE_ACCENTS_FROM and REMOVE_ACCENTS_TO arrays have different lengths. + * * @example * searchWords('John_Doe@Example.com'); // Returns 'john doe example com' * searchWords('McDonald'); // Returns 'mcdonald' @@ -86,10 +88,10 @@ public static function searchWords(?string $words): ?string } // Build combined transformation mapping on first call - if (self::$SEARCH_WORDS_MAPPING === []) { + if (self::$searchWordsMapping === []) { // Start with accent removal mappings (apply strtolower to ensure all replacements are lowercase) $from = [...self::REMOVE_ACCENTS_FROM, ' ']; - $toArray = array_map('strtolower', [...self::REMOVE_ACCENTS_TO, ' ']); + $toArray = array_map(strtolower(...), [...self::REMOVE_ACCENTS_TO, ' ']); if (count($from) !== count($toArray)) { throw new LogicException('REMOVE_ACCENTS_FROM and REMOVE_ACCENTS_TO arrays must have the same length.'); @@ -111,7 +113,7 @@ public static function searchWords(?string $words): ?string } // Combine all mappings for single-pass transformation - self::$SEARCH_WORDS_MAPPING = array_merge( + self::$searchWordsMapping = array_merge( $accentMapping, $specialChars, $uppercaseMapping, @@ -122,7 +124,7 @@ public static function searchWords(?string $words): ?string $words = self::applyBasicNameFix($words); // Single-pass character transformation with strtr() for O(1) lookup - $words = strtr($words, self::$SEARCH_WORDS_MAPPING); + $words = strtr($words, self::$searchWordsMapping); // Final cleanup: reduce multiple spaces to single space and trim return trim(preg_replace('# {2,}#', ' ', $words) ?? ''); @@ -147,6 +149,8 @@ public static function searchWords(?string $words): ?string * * @return null|string The fixed last name according to the standards, or null if input was null. * + * @throws LogicException If REMOVE_ACCENTS_FROM and REMOVE_ACCENTS_TO arrays have different lengths. + * * @example * nameFix('mcdonald'); // Returns 'McDonald' * nameFix('van der waals'); // Returns 'van der Waals' @@ -183,12 +187,12 @@ public static function nameFix(#[\SensitiveParameter] ?string $lastName): ?strin } // Single pass: capitalize words in hyphenated names - $lastName = implode('-', array_map('ucwords', explode('-', $lowerLastName))); + $lastName = implode('-', array_map(ucwords(...), explode('-', $lowerLastName))); // Single pass: fix common prefixes to lowercase $lastName = preg_replace_callback( '#\b(van|von|den|der|des|de|du|la|le)\b#i', - static fn($matches): string => strtolower($matches[1]), + static fn(array $matches): string => strtolower($matches[1]), $lastName, ) ?? ''; @@ -247,13 +251,15 @@ public static function utf8Ansi(?string $value = ''): string * * @return string The transformed string without accents and special characters. * + * @throws LogicException If REMOVE_ACCENTS_FROM and REMOVE_ACCENTS_TO arrays have different lengths. + * * @see REMOVE_ACCENTS_FROM * @see REMOVE_ACCENTS_TO */ public static function removeAccents(string $str): string { // Build associative array for strtr() on first call - if (self::$ACCENTS_REPLACEMENT === []) { + if (self::$accentsReplacement === []) { $from = [...self::REMOVE_ACCENTS_FROM, ' ']; $toArray = [...self::REMOVE_ACCENTS_TO, ' ']; @@ -262,11 +268,11 @@ public static function removeAccents(string $str): string } // Combine parallel arrays into associative array for O(1) lookup - self::$ACCENTS_REPLACEMENT = array_combine($from, $toArray); + self::$accentsReplacement = array_combine($from, $toArray); } // Use strtr() for O(1) character lookup instead of str_replace() O(k) search - return strtr($str, self::$ACCENTS_REPLACEMENT); + return strtr($str, self::$accentsReplacement); } diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..5035f76 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,16 @@ +extend(TestCase::class)->in('Unit'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..25fe061 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,12 @@ +getProperty('SEARCH_WORDS_MAPPING'); + $reflectionProperty = $reflectionClass->getProperty('searchWordsMapping'); $reflectionProperty->setValue(null, []); - $accentsReplacement = $reflectionClass->getProperty('ACCENTS_REPLACEMENT'); + $accentsReplacement = $reflectionClass->getProperty('accentsReplacement'); $accentsReplacement->setValue(null, []); } diff --git a/tests/Unit/CriticalBugFixIntegrationTest.php b/tests/Unit/CriticalBugFixIntegrationTest.php index e6f2ad0..7c2b2a2 100644 --- a/tests/Unit/CriticalBugFixIntegrationTest.php +++ b/tests/Unit/CriticalBugFixIntegrationTest.php @@ -15,10 +15,9 @@ * work correctly in combination. * * @internal - * - * @covers \MarjovanLier\StringManipulation\StringManipulation::searchWords - * @covers \MarjovanLier\StringManipulation\StringManipulation::removeAccents */ +#[\PHPUnit\Framework\Attributes\CoversMethod(\MarjovanLier\StringManipulation\StringManipulation::class, 'searchWords')] +#[\PHPUnit\Framework\Attributes\CoversMethod(\MarjovanLier\StringManipulation\StringManipulation::class, 'removeAccents')] final class CriticalBugFixIntegrationTest extends TestCase { /** @@ -46,10 +45,10 @@ private function resetStaticCache(): void { $reflectionClass = new ReflectionClass(StringManipulation::class); - $reflectionProperty = $reflectionClass->getProperty('SEARCH_WORDS_MAPPING'); + $reflectionProperty = $reflectionClass->getProperty('searchWordsMapping'); $reflectionProperty->setValue(null, []); - $accentsReplacement = $reflectionClass->getProperty('ACCENTS_REPLACEMENT'); + $accentsReplacement = $reflectionClass->getProperty('accentsReplacement'); $accentsReplacement->setValue(null, []); } diff --git a/tests/Unit/IsValidDateTest.php b/tests/Unit/IsValidDateTest.php index 15dd1f5..89a37eb 100644 --- a/tests/Unit/IsValidDateTest.php +++ b/tests/Unit/IsValidDateTest.php @@ -2,347 +2,162 @@ declare(strict_types=1); -namespace MarjovanLier\StringManipulation\Tests\Unit; - use MarjovanLier\StringManipulation\StringManipulation; -use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; /** - * @internal + * Provides a set of valid dates and their respective formats. + * + * @return array> * - * @covers \MarjovanLier\StringManipulation\StringManipulation::isValidDate + * @psalm-return list{list{'2023-09-06 12:30:00', 'Y-m-d H:i:s'}, list{'06-09-2023', 'd-m-Y'}, list{'2023-09-06', + * 'Y-m-d'}, list{'2012-02-28', 'Y-m-d'}, list{'00:00:00', 'H:i:s'}, list{'23:59:59', 'H:i:s'}, + * list{'29-02-2012', 'd-m-Y'}, list{'28-02-2023', 'd-m-Y'}, list{'2023-02-28', 'Y-m-d'}} */ -final class IsValidDateTest extends TestCase -{ - private const string DATE_TIME_FORMAT = 'Y-m-d H:i:s'; - - private const string TIME_FORMAT = 'H:i:s'; - - - /** - * Provides a set of valid dates and their respective formats. - * - * @return array> - * - * @psalm-return list{list{'2023-09-06 12:30:00', 'Y-m-d H:i:s'}, list{'06-09-2023', 'd-m-Y'}, list{'2023-09-06', - * 'Y-m-d'}, list{'2012-02-28', 'Y-m-d'}, list{'00:00:00', 'H:i:s'}, list{'23:59:59', 'H:i:s'}, - * list{'29-02-2012', 'd-m-Y'}, list{'28-02-2023', 'd-m-Y'}, list{'2023-02-28', 'Y-m-d'}} - * - * @suppress PossiblyUnusedMethod - */ - public static function provideValidDates(): array - { - return [ - [ - '2023-09-06 12:30:00', - self::DATE_TIME_FORMAT, - ], - [ - '06-09-2023', - 'd-m-Y', - ], - [ - '2023-09-06', - 'Y-m-d', - ], - [ - '2012-02-28', - 'Y-m-d', - ], - [ - '00:00:00', - self::TIME_FORMAT, - ], - [ - '23:59:59', - self::TIME_FORMAT, - ], - [ - '29-02-2012', - 'd-m-Y', - ], - [ - '28-02-2023', - 'd-m-Y', - ], - [ - '2023-02-28', - 'Y-m-d', - ], - ]; - } - - - /** - * Provides a set of invalid dates and their respective formats. - * - * @return array> - * - * @psalm-return list - */ - public static function provideInvalidDates(): array - { - return [ - [ - '2023-09-06 12:30:00', - 'Y-m-d', - ], - [ - '2023-09-06', - 'd-m-Y', - ], - [ - '06-09-2023', - 'Y-m-d', - ], - [ - '2012-02-30 12:12:12', - self::DATE_TIME_FORMAT, - ], - [ - '2012-02-30 25:12:12', - self::DATE_TIME_FORMAT, - ], - [ - '24:00:00', - self::TIME_FORMAT, - ], - [ - '23:60:00', - self::TIME_FORMAT, - ], - [ - '23:59:60', - self::TIME_FORMAT, - ], - [ - '30-02-2012', - 'd-m-Y', - ], - [ - '31-04-2023', - 'd-m-Y', - ], - [ - '2012-02-30 12:12:12', - self::DATE_TIME_FORMAT, - ], - [ - '2012-02-28 24:12:12', - self::DATE_TIME_FORMAT, - ], - [ - '2012-02-28 23:60:12', - self::DATE_TIME_FORMAT, - ], - [ - '2012-02-28 23:59:60', - self::DATE_TIME_FORMAT, - ], - [ - '0000-00-00 12:30:00', - self::DATE_TIME_FORMAT, - ], - [ - '2023-09-06 12:61:12', - self::DATE_TIME_FORMAT, - ], - [ - '2023-09-06 12:59:61', - self::DATE_TIME_FORMAT, - ], - [ - '2023-09-06 25:30:00', - self::DATE_TIME_FORMAT, - ], - [ - '2023-02-30 12:30:00', - self::DATE_TIME_FORMAT, - ], - [ - '2023-02-30', - 'Y-m-d', - ], - [ - '25:30:00', - self::TIME_FORMAT, - ], - [ - '12:61:00', - self::TIME_FORMAT, - ], - [ - '12:59:61', - self::TIME_FORMAT, - ], - ]; - } - - - /** - * @dataProvider provideValidDates - */ - #[DataProvider('provideValidDates')] - public function testValidDates(string $date, string $format): void - { - self::assertTrue(StringManipulation::isValidDate($date, $format)); - } - - - /** - * @dataProvider provideInvalidDates - */ - #[DataProvider('provideInvalidDates')] - public function testInvalidDates(string $date, string $format): void - { - self::assertFalse(StringManipulation::isValidDate($date, $format)); - } - - - /** - * Test edge cases and boundary conditions for date validation. - */ - public function testDateValidationEdgeCases(): void - { - // Leap year edge cases - self::assertTrue(StringManipulation::isValidDate('2000-02-29', 'Y-m-d')); // Leap year (divisible by 400) - self::assertTrue(StringManipulation::isValidDate('2004-02-29', 'Y-m-d')); // Leap year (divisible by 4) - self::assertFalse(StringManipulation::isValidDate('1900-02-29', 'Y-m-d')); // Not leap year (divisible by 100, not 400) - self::assertFalse(StringManipulation::isValidDate('2023-02-29', 'Y-m-d')); // Not leap year - - // Month boundary cases - self::assertTrue(StringManipulation::isValidDate('2023-01-31', 'Y-m-d')); // January 31 days - self::assertFalse(StringManipulation::isValidDate('2023-02-31', 'Y-m-d')); // February doesn't have 31 days - self::assertTrue(StringManipulation::isValidDate('2023-03-31', 'Y-m-d')); // March 31 days - self::assertFalse(StringManipulation::isValidDate('2023-04-31', 'Y-m-d')); // April only has 30 days - self::assertTrue(StringManipulation::isValidDate('2023-05-31', 'Y-m-d')); // May 31 days - self::assertFalse(StringManipulation::isValidDate('2023-06-31', 'Y-m-d')); // June only has 30 days - self::assertTrue(StringManipulation::isValidDate('2023-12-31', 'Y-m-d')); // December 31 days - - // Time boundary cases - self::assertTrue(StringManipulation::isValidDate('00:00:00', 'H:i:s')); // Midnight - self::assertTrue(StringManipulation::isValidDate('23:59:59', 'H:i:s')); // Last second of day - self::assertFalse(StringManipulation::isValidDate('24:00:00', 'H:i:s')); // Invalid hour - self::assertFalse(StringManipulation::isValidDate('23:60:00', 'H:i:s')); // Invalid minute - self::assertFalse(StringManipulation::isValidDate('23:59:60', 'H:i:s')); // Invalid second - - // Year boundary cases - self::assertTrue(StringManipulation::isValidDate('0001-01-01', 'Y-m-d')); // Minimum year - self::assertTrue(StringManipulation::isValidDate('9999-12-31', 'Y-m-d')); // Maximum year - self::assertFalse(StringManipulation::isValidDate('0000-01-01', 'Y-m-d')); // Year 0 doesn't exist - - // Complex format edge cases - self::assertTrue(StringManipulation::isValidDate('31-12-2023 23:59:59', 'd-m-Y H:i:s')); - self::assertFalse(StringManipulation::isValidDate('32-12-2023 23:59:59', 'd-m-Y H:i:s')); - - // Different separators - self::assertTrue(StringManipulation::isValidDate('2023/12/31', 'Y/m/d')); - self::assertTrue(StringManipulation::isValidDate('2023.12.31', 'Y.m.d')); - self::assertTrue(StringManipulation::isValidDate('31.12.2023', 'd.m.Y')); - } - - - /** - * Test performance and stress scenarios for date validation. - */ - public function testDateValidationPerformance(): void - { - // Large batch of date validations - $dates = []; - for ($year = 2020; $year <= 2025; ++$year) { - for ($month = 1; $month <= 12; ++$month) { - for ($day = 1; $day <= 28; ++$day) { // Safe range for all months - $dates[] = sprintf('%04d-%02d-%02d', $year, $month, $day); - } - } - } - - $startTime = microtime(true); - $validCount = 0; - foreach ($dates as $date) { - if (StringManipulation::isValidDate($date, 'Y-m-d')) { - ++$validCount; - } - } - - $duration = microtime(true) - $startTime; - - self::assertEquals(count($dates), $validCount); // All should be valid - self::assertLessThan(2.0, $duration, 'Batch date validation should complete efficiently'); - - // Stress test with complex formats - $complexDates = [ - '2023-12-31 23:59:59', - '31-12-2023 00:00:00', - '2023/01/01 12:30:45', - '01.01.2023 06:15:30', - ]; - - $formats = [ - 'Y-m-d H:i:s', - 'd-m-Y H:i:s', - 'Y/m/d H:i:s', - 'd.m.Y H:i:s', - ]; - - $startTime = microtime(true); - foreach ($complexDates as $index => $date) { - self::assertTrue(StringManipulation::isValidDate($date, $formats[$index])); - } - - $duration = microtime(true) - $startTime; - self::assertLessThan(0.1, $duration, 'Complex format validation should be fast'); - } - - - /** - * Test negative flow scenarios for date validation. - */ - public function testDateValidationNegativeFlow(): void - { - // Malformed dates - $malformedDates = [ - ['', 'Y-m-d'], - ['not-a-date', 'Y-m-d'], - ['2023-13-01', 'Y-m-d'], // Invalid month - ['2023-00-01', 'Y-m-d'], // Invalid month - ['2023-01-00', 'Y-m-d'], // Invalid day - ['2023-01-32', 'Y-m-d'], // Invalid day - ['2023/01/01', 'Y-m-d'], // Wrong separator - ['2023-1-1', 'Y-m-d'], // Missing zero padding - ]; - - foreach ($malformedDates as [$date, $format]) { - self::assertFalse(StringManipulation::isValidDate($date, $format), sprintf("Date '%s' should be invalid for format '%s'", $date, $format)); - } - - // Time validation edge cases - $invalidTimes = [ - ['25:00:00', 'H:i:s'], // Hour too high - ['12:60:00', 'H:i:s'], // Minute too high - ['12:30:60', 'H:i:s'], // Second too high - ['-1:30:00', 'H:i:s'], // Negative hour - ['12:-1:00', 'H:i:s'], // Negative minute - ['12:30:-1', 'H:i:s'], // Negative second - ]; - - foreach ($invalidTimes as [$time, $format]) { - self::assertFalse(StringManipulation::isValidDate($time, $format), sprintf("Time '%s' should be invalid for format '%s'", $time, $format)); - } - - // Format mismatch scenarios - $formatMismatches = [ - ['2023-01-01', 'd-m-Y'], // Date format doesn't match - ['01-01-2023', 'Y-m-d'], // Date format doesn't match - ['2023-01-01 12:30:00', 'Y-m-d'], // Extra time component - ['12:30:00', 'Y-m-d H:i:s'], // Missing date component - ['2023', 'Y-m-d'], // Incomplete date - ['01-01', 'Y-m-d'], // Incomplete date - ]; - - foreach ($formatMismatches as [$date, $format]) { - self::assertFalse(StringManipulation::isValidDate($date, $format), sprintf("Date '%s' should not match format '%s'", $date, $format)); - } - } -} +dataset('provideValidDates', fn(): array => [ + + [ + '2023-09-06 12:30:00', + 'Y-m-d H:i:s', + ], + [ + '06-09-2023', + 'd-m-Y', + ], + [ + '2023-09-06', + 'Y-m-d', + ], + [ + '2012-02-28', + 'Y-m-d', + ], + [ + '00:00:00', + 'H:i:s', + ], + [ + '23:59:59', + 'H:i:s', + ], + [ + '29-02-2012', + 'd-m-Y', + ], + [ + '28-02-2023', + 'd-m-Y', + ], + [ + '2023-02-28', + 'Y-m-d', + ], +]); +/** + * Provides a set of invalid dates and their respective formats. + * + * @return array> + * + * @psalm-return list + */ +dataset('provideInvalidDates', fn(): array => [ + + [ + '2023-09-06 12:30:00', + 'Y-m-d', + ], + [ + '2023-09-06', + 'd-m-Y', + ], + [ + '06-09-2023', + 'Y-m-d', + ], + [ + '2012-02-30 12:12:12', + 'Y-m-d H:i:s', + ], + [ + '2012-02-30 25:12:12', + 'Y-m-d H:i:s', + ], + [ + '24:00:00', + 'H:i:s', + ], + [ + '23:60:00', + 'H:i:s', + ], + [ + '23:59:60', + 'H:i:s', + ], + [ + '30-02-2012', + 'd-m-Y', + ], + [ + '31-04-2023', + 'd-m-Y', + ], + [ + '2012-02-30 12:12:12', + 'Y-m-d H:i:s', + ], + [ + '2012-02-28 24:12:12', + 'Y-m-d H:i:s', + ], + [ + '2012-02-28 23:60:12', + 'Y-m-d H:i:s', + ], + [ + '2012-02-28 23:59:60', + 'Y-m-d H:i:s', + ], + [ + '0000-00-00 12:30:00', + 'Y-m-d H:i:s', + ], + [ + '2023-09-06 12:61:12', + 'Y-m-d H:i:s', + ], + [ + '2023-09-06 12:59:61', + 'Y-m-d H:i:s', + ], + [ + '2023-09-06 25:30:00', + 'Y-m-d H:i:s', + ], + [ + '2023-02-30 12:30:00', + 'Y-m-d H:i:s', + ], + [ + '2023-02-30', + 'Y-m-d', + ], + [ + '25:30:00', + 'H:i:s', + ], + [ + '12:61:00', + 'H:i:s', + ], + [ + '12:59:61', + 'H:i:s', + ], +]); +test('valid dates', function (string $date, string $format): void { + expect(StringManipulation::isValidDate($date, $format))->toBeTrue(); + +})->with('provideValidDates'); +test('invalid dates', function (string $date, string $format): void { + expect(StringManipulation::isValidDate($date, $format))->toBeFalse(); +})->with('provideInvalidDates'); diff --git a/tests/Unit/IsValidHourTest.php b/tests/Unit/IsValidHourTest.php deleted file mode 100644 index 5adee4f..0000000 --- a/tests/Unit/IsValidHourTest.php +++ /dev/null @@ -1,80 +0,0 @@ -> - * - * @psalm-return list{list{0, true}, list{12, true}, list{23, true}, list{30, false}, list{59, false}, list{-1, - * false}, list{60, false}, list{100, false}} - */ - public static function provideHours(): array - { - return [ - [ - 0, - true, - ], - [ - 12, - true, - ], - [ - 23, - true, - ], - [ - 30, - false, - ], - [ - 59, - false, - ], - [ - -1, - false, - ], - [ - 60, - false, - ], - [ - 100, - false, - ], - ]; - } - - - /** - * Tests the isValidHour method. - * - * @dataProvider provideHours - */ - #[DataProvider('provideHours')] - public function testIsValidHour(int $hour, bool $expectedResult): void - { - $reflectionMethod = (new ReflectionClass(StringManipulation::class))->getMethod('isValidHour'); - - $result = $reflectionMethod->invoke(null, $hour); - - self::assertSame($expectedResult, $result); - } -} diff --git a/tests/Unit/IsValidMinuteTest.php b/tests/Unit/IsValidMinuteTest.php deleted file mode 100644 index 35c9bb3..0000000 --- a/tests/Unit/IsValidMinuteTest.php +++ /dev/null @@ -1,72 +0,0 @@ -> - * - * @psalm-return list{list{0, true}, list{30, true}, list{59, true}, list{-1, false}, list{60, false}, list{100, - * false}} - */ - public static function provideMinutes(): array - { - return [ - [ - 0, - true, - ], - [ - 30, - true, - ], - [ - 59, - true, - ], - [ - -1, - false, - ], - [ - 60, - false, - ], - [ - 100, - false, - ], - ]; - } - - - /** - * Tests the isValidMinute method. - * - * @dataProvider provideMinutes - */ - #[DataProvider('provideMinutes')] - public function testIsValidMinute(int $minute, bool $expectedResult): void - { - $reflectionMethod = (new ReflectionClass(StringManipulation::class))->getMethod('isValidMinute'); - - $result = $reflectionMethod->invoke(null, $minute); - - self::assertSame($expectedResult, $result); - } -} diff --git a/tests/Unit/IsValidSecondTest.php b/tests/Unit/IsValidSecondTest.php deleted file mode 100644 index cb0a6e0..0000000 --- a/tests/Unit/IsValidSecondTest.php +++ /dev/null @@ -1,72 +0,0 @@ -> - * - * @psalm-return list{list{0, true}, list{30, true}, list{59, true}, list{-1, false}, list{60, false}, list{100, - * false}} - */ - public static function provideSeconds(): array - { - return [ - [ - 0, - true, - ], - [ - 30, - true, - ], - [ - 59, - true, - ], - [ - -1, - false, - ], - [ - 60, - false, - ], - [ - 100, - false, - ], - ]; - } - - - /** - * Tests the isValidSecond method. - * - * @dataProvider provideSeconds - */ - #[DataProvider('provideSeconds')] - public function testIsValidSecond(int $second, bool $expectedResult): void - { - $reflectionMethod = (new ReflectionClass(StringManipulation::class))->getMethod('isValidSecond'); - - $result = $reflectionMethod->invoke(null, $second); - - self::assertSame($expectedResult, $result); - } -} diff --git a/tests/Unit/IsValidTimePartTest.php b/tests/Unit/IsValidTimePartTest.php deleted file mode 100644 index 9f67d29..0000000 --- a/tests/Unit/IsValidTimePartTest.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ - public static function provideValidTimeParts(): array - { - return [ - 'midnight' => [['year' => 2023, 'month' => 12, 'day' => 25, 'hour' => 0, 'minute' => 0, 'second' => 0], true], - 'end of day' => [['year' => 2023, 'month' => 12, 'day' => 25, 'hour' => 23, 'minute' => 59, 'second' => 59], true], - ]; - } - - /** - * Provides invalid time parts for testing. - * - * @return array - */ - public static function provideInvalidTimeParts(): array - { - return [ - 'negative hour' => [['year' => 2023, 'month' => 12, 'day' => 25, 'hour' => -1, 'minute' => 0, 'second' => 0], false], - 'hour 24' => [['year' => 2023, 'month' => 12, 'day' => 25, 'hour' => 24, 'minute' => 0, 'second' => 0], false], - 'negative minute' => [['year' => 2023, 'month' => 12, 'day' => 25, 'hour' => 0, 'minute' => -1, 'second' => 0], false], - 'minute 60' => [['year' => 2023, 'month' => 12, 'day' => 25, 'hour' => 0, 'minute' => 60, 'second' => 0], false], - 'negative second' => [['year' => 2023, 'month' => 12, 'day' => 25, 'hour' => 0, 'minute' => 0, 'second' => -1], false], - 'second 60' => [['year' => 2023, 'month' => 12, 'day' => 25, 'hour' => 0, 'minute' => 0, 'second' => 60], false], - 'invalid date Feb 30' => [['year' => 2023, 'month' => 2, 'day' => 30, 'hour' => 12, 'minute' => 0, 'second' => 0], false], - 'invalid month 13' => [['year' => 2023, 'month' => 13, 'day' => 1, 'hour' => 12, 'minute' => 0, 'second' => 0], false], - ]; - } - - /** - * Provides all time parts for testing. - * - * @return array - */ - public static function provideTimeParts(): array - { - return array_merge( - self::provideValidTimeParts(), - self::provideInvalidTimeParts(), - ); - } - - - /** - * Tests the isValidTimePart method. - * - * @param array{year?: int, month?: int, day?: int, hour: int, minute: int, second: int} $timeParts - * - * @dataProvider provideTimeParts - */ - #[DataProvider('provideTimeParts')] - public function testIsValidTimePart(array $timeParts, bool $expectedResult): void - { - $reflectionMethod = (new ReflectionClass(StringManipulation::class))->getMethod('isValidTimePart'); - - $result = $reflectionMethod->invoke(null, $timeParts); - - self::assertSame($expectedResult, $result); - } -} diff --git a/tests/Unit/NameFixComprehensiveTest.php b/tests/Unit/NameFixComprehensiveTest.php index d172209..fc19fc1 100644 --- a/tests/Unit/NameFixComprehensiveTest.php +++ b/tests/Unit/NameFixComprehensiveTest.php @@ -9,15 +9,16 @@ /** * @internal - * - * @covers \MarjovanLier\StringManipulation\StringManipulation::nameFix - * - * Happy path test suite for nameFix function covering standard international names, - * common prefixes, and typical name formatting scenarios that should work correctly. - * - * This class focuses on the positive/happy flow scenarios where inputs are - * well-formed and expected to produce standard formatted output. */ +#[\PHPUnit\Framework\Attributes\CoversMethod(\MarjovanLier\StringManipulation\StringManipulation::class . '::nameFix +Happy path test suite for nameFix function covering standard international names, +common prefixes, and typical name formatting scenarios that should work correctly. +This class focuses on the positive/happy flow scenarios where inputs are +well-formed and expected to produce standard formatted output.::class', 'nameFix +Happy path test suite for nameFix function covering standard international names, +common prefixes, and typical name formatting scenarios that should work correctly. +This class focuses on the positive/happy flow scenarios where inputs are +well-formed and expected to produce standard formatted output.')] final class NameFixComprehensiveTest extends TestCase { /** diff --git a/tests/Unit/NameFixEdgeCasesTest.php b/tests/Unit/NameFixEdgeCasesTest.php index 827b8b7..3bf7276 100644 --- a/tests/Unit/NameFixEdgeCasesTest.php +++ b/tests/Unit/NameFixEdgeCasesTest.php @@ -9,12 +9,12 @@ /** * @internal - * - * @covers \MarjovanLier\StringManipulation\StringManipulation::nameFix - * - * Edge case test suite for nameFix function covering boundary conditions, - * unusual but valid inputs, and corner cases that should still work correctly. */ +#[\PHPUnit\Framework\Attributes\CoversMethod(\MarjovanLier\StringManipulation\StringManipulation::class . '::nameFix +Edge case test suite for nameFix function covering boundary conditions, +unusual but valid inputs, and corner cases that should still work correctly.::class', 'nameFix +Edge case test suite for nameFix function covering boundary conditions, +unusual but valid inputs, and corner cases that should still work correctly.')] final class NameFixEdgeCasesTest extends TestCase { /** diff --git a/tests/Unit/NameFixNegativeFlowTest.php b/tests/Unit/NameFixNegativeFlowTest.php index cd84f2d..59e0a33 100644 --- a/tests/Unit/NameFixNegativeFlowTest.php +++ b/tests/Unit/NameFixNegativeFlowTest.php @@ -9,12 +9,12 @@ /** * @internal - * - * @covers \MarjovanLier\StringManipulation\StringManipulation::nameFix - * - * Negative flow test suite for nameFix function covering malformed inputs, - * boundary conditions, security concerns, and error scenarios. */ +#[\PHPUnit\Framework\Attributes\CoversMethod(\MarjovanLier\StringManipulation\StringManipulation::class . '::nameFix +Negative flow test suite for nameFix function covering malformed inputs, +boundary conditions, security concerns, and error scenarios.::class', 'nameFix +Negative flow test suite for nameFix function covering malformed inputs, +boundary conditions, security concerns, and error scenarios.')] final class NameFixNegativeFlowTest extends TestCase { /** diff --git a/tests/Unit/NameFixSpecialCharactersTest.php b/tests/Unit/NameFixSpecialCharactersTest.php index a855afb..d3fa5e3 100644 --- a/tests/Unit/NameFixSpecialCharactersTest.php +++ b/tests/Unit/NameFixSpecialCharactersTest.php @@ -9,12 +9,12 @@ /** * @internal - * - * @covers \MarjovanLier\StringManipulation\StringManipulation::nameFix - * - * Special characters and complex scenarios test suite for nameFix function. - * Covers names with numbers, special characters, and complex real-world combinations. */ +#[\PHPUnit\Framework\Attributes\CoversMethod(\MarjovanLier\StringManipulation\StringManipulation::class . '::nameFix +Special characters and complex scenarios test suite for nameFix function. +Covers names with numbers, special characters, and complex real-world combinations.::class', 'nameFix +Special characters and complex scenarios test suite for nameFix function. +Covers names with numbers, special characters, and complex real-world combinations.')] final class NameFixSpecialCharactersTest extends TestCase { /** diff --git a/tests/Unit/NameFixTest.php b/tests/Unit/NameFixTest.php index 8adee13..b4a9306 100644 --- a/tests/Unit/NameFixTest.php +++ b/tests/Unit/NameFixTest.php @@ -2,116 +2,106 @@ declare(strict_types=1); -namespace MarjovanLier\StringManipulation\Tests\Unit; - use MarjovanLier\StringManipulation\StringManipulation; -use PHPUnit\Framework\TestCase; - -/** - * @internal - * - * @covers \MarjovanLier\StringManipulation\StringManipulation::nameFix - * - * This class is a test case for the nameFix function in the StringManipulation class. - * It tests the function with a variety of inputs. - */ -final class NameFixTest extends TestCase -{ - /** - * Test the nameFix function with a variety of inputs. - * - * This function tests the nameFix function with a variety of names, including basic and advanced name handling. - * It also includes negative tests where the input is not a name. - */ - public function testNameFixFunction(): void - { - // Basic and advanced name handling - $names = [ - 'de la hoya' => 'de la Hoya', - 'de la tòrré' => 'de la Torre', - 'donald' => 'Donald', - 'johnson' => 'Johnson', - 'macarthur' => 'MacArthur', - ' macdonald ' => 'MacDonald', - 'macdonald-smith-jones' => 'MacDonald-Smith-Jones', - 'mACdonald-sMith-jOnes' => 'MacDonald-Smith-Jones', - 'MacDonald-sMith-jOnes' => 'MacDonald-Smith-Jones', - 'macIntosh' => 'MacIntosh', - 'mac jones' => 'Mac Jones', - 'macjones' => 'MacJones', - 'mcdonald' => 'McDonald', - 'MCDONALD' => 'McDonald', - ' mcDonald ' => 'McDonald', - 'Mc donald' => 'Mc Donald', - 'mcdónald' => 'McDonald', - 'o’reilly' => "O'reilly", - 'van der saar' => 'van der Saar', - 'VAN LIER' => 'van Lier', - 'À Macdonald È' => 'A MacDonald E', - ]; - - foreach ($names as $input => $expected) { - // For each name, we assert that the output of the nameFix function is equal to the expected output. - self::assertEquals($expected, StringManipulation::nameFix($input)); - } - - // Negative tests - $negativeTests = [ - '!@#$%' => '!@#$%', - ]; - - foreach ($negativeTests as $input => $expected) { - // For each negative test, we assert that the output of the nameFix function is equal to the input. - self::assertEquals($expected, StringManipulation::nameFix($input)); - } - - // Test null input separately - self::assertNull(StringManipulation::nameFix(null)); + +test('name fix function', function (): void { + + // Basic and advanced name handling + $names = [ + 'de la hoya' => 'de la Hoya', + 'de la tòrré' => 'de la Torre', + 'donald' => 'Donald', + 'johnson' => 'Johnson', + 'macarthur' => 'MacArthur', + ' macdonald ' => 'MacDonald', + 'macdonald-smith-jones' => 'MacDonald-Smith-Jones', + 'mACdonald-sMith-jOnes' => 'MacDonald-Smith-Jones', + 'MacDonald-sMith-jOnes' => 'MacDonald-Smith-Jones', + 'macIntosh' => 'MacIntosh', + 'mac jones' => 'Mac Jones', + 'macjones' => 'MacJones', + 'mcdonald' => 'McDonald', + 'MCDONALD' => 'McDonald', + ' mcDonald ' => 'McDonald', + 'Mc donald' => 'Mc Donald', + 'mcdónald' => 'McDonald', + 'o’reilly' => "O'reilly", + 'van der saar' => 'van der Saar', + 'VAN LIER' => 'van Lier', + 'À Macdonald È' => 'A MacDonald E', + ]; + + foreach ($names as $input => $expected) { + // For each name, we assert that the output of the nameFix function is equal to the expected output. + expect(StringManipulation::nameFix($input))->toBe($expected); } + // Negative tests + $negativeTests = [ + '!@#$%' => '!@#$%', + ]; - /** - * Test the nameFix function with numeric input. - * - * This function tests the nameFix function with a numeric input. - * The function is expected to return the input as is in this case. - */ - public function testNameFixWithNumericInput(): void - { - self::assertEquals('12345', StringManipulation::nameFix('12345')); + foreach ($negativeTests as $input => $expected) { + // For each negative test, we assert that the output of the nameFix function is equal to the input. + expect(StringManipulation::nameFix($input))->toBe($expected); } + // Test null input separately + expect(StringManipulation::nameFix(null))->toBeNull(); +}); - /** - * Test that Mac/Mc prefix handling requires both conditions to be true. - * This targets the LogicalAnd mutations in the nameFix function. - */ - public function testMacMcPrefixHandlingLogicalConditions(): void - { - // Test cases where 'mc' exists but is followed by a space (should NOT trigger fix) - self::assertEquals('Mc Donald', StringManipulation::nameFix('mc donald')); - self::assertEquals('Mc Lean', StringManipulation::nameFix('mc lean')); +test('name fix with numeric input', function (): void { - // Test cases where 'mac' exists but is followed by a space (should NOT trigger fix) - self::assertEquals('Mac Donald', StringManipulation::nameFix('mac donald')); - self::assertEquals('Mac Lean', StringManipulation::nameFix('mac lean')); + expect(StringManipulation::nameFix('12345'))->toBe('12345'); +}); - // Test cases where 'mc' exists and is NOT followed by a space (SHOULD trigger fix) - self::assertEquals('McDonald', StringManipulation::nameFix('mcdonald')); - self::assertEquals('McLean', StringManipulation::nameFix('mclean')); +test('name fix handles empty string correctly', function (): void { + // Lines 136, 167 mutations: EmptyStringToNotEmpty + // Test that preg_replace returning null is handled correctly + expect(StringManipulation::nameFix(''))->toBe(''); +}); - // Test cases where 'mac' exists and is NOT followed by a space (SHOULD trigger fix) - self::assertEquals('MacDonald', StringManipulation::nameFix('macdonald')); - self::assertEquals('MacLean', StringManipulation::nameFix('maclean')); +test('name fix handles mc prefix edge cases', function (): void { + // Line 144 mutation: BooleanAndToBooleanOr + // Test that both conditions must be true: contains 'mc' AND regex matches 'mc' without space - // Test cases where prefix doesn't exist at all - self::assertEquals("O'brien", StringManipulation::nameFix("o'brien")); - self::assertEquals('Johnson', StringManipulation::nameFix('johnson')); + // Contains 'mc' but WITH space after it - should NOT trigger mcFix + expect(StringManipulation::nameFix('mc donald'))->toBe('Mc Donald'); - // Test complex cases with multiple occurrences - self::assertEquals('MacDonald-McDonald', StringManipulation::nameFix('macdonald-mcdonald')); + // Contains 'mc' AND WITHOUT space after it - should trigger mcFix + expect(StringManipulation::nameFix('mcdonald'))->toBe('McDonald'); - // Test edge case where both conditions in OR would be true but should only trigger once - self::assertEquals('MacDonald Mac Smith', StringManipulation::nameFix('macdonald mac smith')); - } -} + // Does NOT contain 'mc' at all - should not trigger mcFix + expect(StringManipulation::nameFix('donald'))->toBe('Donald'); +}); + +test('name fix handles mac prefix edge cases', function (): void { + // Line 151 mutation: BooleanAndToBooleanOr + // Test that both conditions must be true: contains 'mac' AND regex matches 'mac' without space + + // Contains 'mac' but WITH space after it - should NOT trigger macFix + expect(StringManipulation::nameFix('mac donald'))->toBe('Mac Donald'); + + // Contains 'mac' AND WITHOUT space after it - should trigger macFix + expect(StringManipulation::nameFix('macdonald'))->toBe('MacDonald'); + + // Does NOT contain 'mac' at all - should not trigger macFix + expect(StringManipulation::nameFix('donald'))->toBe('Donald'); +}); + +test('name fix handles name prefixes correctly', function (): void { + // Line 162 mutation: DecrementInteger + // Tests that the regex callback uses $matches[1] (captured group) not $matches[0] (full match) + // The regex pattern captures the prefix in group 1, and we need to lowercase only that group + + // Test van prefix + expect(StringManipulation::nameFix('VAN LIER'))->toBe('van Lier'); + expect(StringManipulation::nameFix('Van Lier'))->toBe('van Lier'); + + // Test von prefix + expect(StringManipulation::nameFix('VON SMITH'))->toBe('von Smith'); + + // Test multiple prefixes + expect(StringManipulation::nameFix('Van Der Saar'))->toBe('van der Saar'); + expect(StringManipulation::nameFix('De La Hoya'))->toBe('de la Hoya'); +}); diff --git a/tests/Unit/RemoveAccentsTest.php b/tests/Unit/RemoveAccentsTest.php index c3ea98d..fa8a3ae 100644 --- a/tests/Unit/RemoveAccentsTest.php +++ b/tests/Unit/RemoveAccentsTest.php @@ -2,311 +2,120 @@ declare(strict_types=1); -namespace MarjovanLier\StringManipulation\Tests\Unit; - use MarjovanLier\StringManipulation\StringManipulation; -use PHPUnit\Framework\TestCase; - -/** - * @internal - * - * @covers \MarjovanLier\StringManipulation\StringManipulation::removeAccents - */ -final class RemoveAccentsTest extends TestCase -{ - /** - * Test the removeAccents function. - */ - public function testRemoveAccentsFunction(): void - { - self::assertEquals('aeiou', StringManipulation::removeAccents('áéíóú')); - self::assertEquals('AEIOU', StringManipulation::removeAccents('ÁÉÍÓÚ')); - self::assertEquals('AeOeUe', StringManipulation::removeAccents('ÄëÖëÜë')); - self::assertEquals('Nino', StringManipulation::removeAccents('Niño')); - self::assertEquals("cote d'Ivoire", StringManipulation::removeAccents('côte d’Ivoire')); - } - - - /** - * Negative tests for the removeAccents function. - */ - public function testRemoveAccentsFunctionNegative(): void - { - // Passing empty string - self::assertEquals('', StringManipulation::removeAccents('')); - - // Passing numbers - self::assertEquals('12345', StringManipulation::removeAccents('12345')); - - // Passing special characters - self::assertEquals('!@#$%', StringManipulation::removeAccents('!@#$%')); - - // Passing a string without accents - self::assertEquals('abcdef', StringManipulation::removeAccents('abcdef')); - } - - - public function testRemoveAccents(): void - { - $string = 'ÀÁÂÃÄÅ'; - $result = StringManipulation::removeAccents($string); - self::assertEquals('AAAAAA', $result); - } - - - public function testRemoveAccentsWithNoAccents(): void - { - $string = 'ABCDEF'; - $result = StringManipulation::removeAccents($string); - self::assertEquals('ABCDEF', $result); - } - - - /** - * Test comprehensive Unicode accent removal. - */ - public function testRemoveAccentsComprehensive(): void - { - // Latin Extended-A accents - self::assertEquals('AaeE', StringManipulation::removeAccents('ĀāėĖ')); - self::assertEquals('IiOoUu', StringManipulation::removeAccents('ĪīŌōŪū')); - - // Latin Extended-B accents - self::assertEquals('SsZz', StringManipulation::removeAccents('ŠšŽž')); - self::assertEquals('CcDd', StringManipulation::removeAccents('ČčĎď')); - - // French accents comprehensive - self::assertEquals('eEeEaAcCuUiI', StringManipulation::removeAccents('éÉèÈàÀçÇùÙîÎ')); - self::assertEquals('oOaAeE', StringManipulation::removeAccents('ôÔâÂêÊ')); - - // German umlauts - self::assertEquals('AOU', StringManipulation::removeAccents('ÄÖÜ')); - self::assertEquals('aou', StringManipulation::removeAccents('äöü')); - self::assertEquals('s', StringManipulation::removeAccents('ß')); - - // Spanish characters - self::assertEquals('Nn', StringManipulation::removeAccents('Ññ')); - self::assertEquals('¡¿', StringManipulation::removeAccents('¡¿')); - - // Portuguese accents - self::assertEquals('aoAO', StringManipulation::removeAccents('ãõÃÕ')); - - // Italian accents - self::assertEquals('eE', StringManipulation::removeAccents('èÈ')); - - // Mixed language text - self::assertEquals( - 'Cafe Restauraǹt Menu', - StringManipulation::removeAccents('Café Restauraǹt Menü'), - ); - - // Complex accented words - self::assertEquals( - 'Constantinople', - StringManipulation::removeAccents('Constantinoplë'), - ); - - self::assertEquals( - 'Francais', - StringManipulation::removeAccents('Français'), - ); - - self::assertEquals( - 'Munchen', - StringManipulation::removeAccents('München'), - ); - } - - - /** - * Test accent removal with numbers and mixed content. - */ - public function testRemoveAccentsWithMixedContent(): void - { - // Text with numbers - self::assertEquals( - 'Address 123 Rue de la Paix', - StringManipulation::removeAccents('Addréss 123 Ruë de là Pàix'), - ); - - // Text with special characters - self::assertEquals( - 'Email: user@domain com', - StringManipulation::removeAccents('Emaíl: ùser@domaín.cóm'), - ); - - // Mixed case with symbols - self::assertEquals( - 'Price: $19 99 (15% off)', - StringManipulation::removeAccents('Pricé: $19.99 (15% óff)'), - ); - } - - - /** - * Test accent removal performance and edge cases. - */ - public function testRemoveAccentsPerformanceEdgeCases(): void - { - // Long string with many accents - $longAccentedString = str_repeat('áéíóúàèìòùâêîôûäëïöü', 100); - $expectedLongString = str_repeat('aeiouaeiouaeiouaeiou', 100); - self::assertEquals($expectedLongString, StringManipulation::removeAccents($longAccentedString)); - - // Single character tests - self::assertEquals('a', StringManipulation::removeAccents('á')); - self::assertEquals('A', StringManipulation::removeAccents('À')); - - // Repetitive accent patterns - self::assertEquals('aaaa', StringManipulation::removeAccents('áàâä')); - self::assertEquals('eeee', StringManipulation::removeAccents('éèêë')); - self::assertEquals('iiii', StringManipulation::removeAccents('íìîï')); - self::assertEquals('oooo', StringManipulation::removeAccents('óòôö')); - self::assertEquals('uuuu', StringManipulation::removeAccents('úùûü')); - } - - - /** - * Test accent removal with various text patterns. - */ - public function testRemoveAccentsTextPatterns(): void - { - // Words with multiple accent types - self::assertEquals( - 'communication', - StringManipulation::removeAccents('cómmunìcâtion'), - ); - - // Sentences with mixed accents - self::assertEquals( - 'The quick brown fox jumps over the lazy dog ', - StringManipulation::removeAccents('Thé qüick bröwn fóx jümps ovér thè läzy dög.'), - ); - - // All capitals with accents - self::assertEquals( - 'MAXIMUM EFFICIENCY', - StringManipulation::removeAccents('MÀXIMÜM ÈFFICIÉNCY'), - ); - - // Text with quotes and accents - self::assertEquals( - '"Hello", said the visitor ', - StringManipulation::removeAccents('"Hëllo", saíd thé visítör.'), - ); - } - - - /** - * Test negative flow scenarios for removeAccents function. - */ - public function testRemoveAccentsNegativeFlow(): void - { - // Malformed Unicode sequences - $malformedUtf8 = "\xFF\xFE\xFD"; - $result = StringManipulation::removeAccents($malformedUtf8); - self::assertEquals($malformedUtf8, $result); - - // Invalid character encodings (non-UTF-8) - $invalidEncoding = "\x80\x81\x82\x83"; - $result = StringManipulation::removeAccents($invalidEncoding); - self::assertEquals($invalidEncoding, $result); - - // Binary data mixed with text - $binaryMixed = "Hello\x00\x01\x02World"; - $result = StringManipulation::removeAccents($binaryMixed); - self::assertEquals($binaryMixed, $result); - - // Very long string with performance implications - $veryLongString = str_repeat('áéíóúàèìòùâêîôûäëïöü', 10000); - $startTime = microtime(true); - $result = StringManipulation::removeAccents($veryLongString); - $duration = microtime(true) - $startTime; - self::assertLessThan(2.0, $duration, 'RemoveAccents should handle large strings efficiently'); - self::assertStringNotContainsString('á', $result); - - // Unicode normalisation edge cases - $denormalised = "e\u{0301}"; // e + combining acute accent - $result = StringManipulation::removeAccents($denormalised); - // This should handle combining characters appropriately - self::assertNotEmpty($result); - - // High Unicode ranges not typically handled - $highUnicode = "\u{1F600}\u{1F601}"; // Emoji - $result = StringManipulation::removeAccents($highUnicode); - self::assertEquals($highUnicode, $result); - - // Mixed script text (Cyrillic, Greek, etc.) - $cyrillicText = 'Привет мир'; // Russian - $result = StringManipulation::removeAccents($cyrillicText); - self::assertEquals($cyrillicText, $result); - - $greekText = 'Γεια σας'; // Greek - $result = StringManipulation::removeAccents($greekText); - self::assertEquals($greekText, $result); - - // Control characters mixed with accented text - $controlMixed = "\x01\x02á\x03é\x04"; - $result = StringManipulation::removeAccents($controlMixed); - self::assertEquals("\x01\x02a\x03e\x04", $result); - - // Null bytes in string - $nullByteString = "caf\0é"; - $result = StringManipulation::removeAccents($nullByteString); - self::assertEquals("caf\0e", $result); - } - - - /** - * Test edge cases and boundary conditions for removeAccents. - */ - public function testRemoveAccentsEdgeCases(): void - { - // Single accented character - self::assertEquals('a', StringManipulation::removeAccents('á')); - self::assertEquals('A', StringManipulation::removeAccents('À')); - - // String with only accents - $onlyAccents = 'áéíóúàèìòùâêîôûäëïöü'; - $result = StringManipulation::removeAccents($onlyAccents); - self::assertStringNotContainsString('á', $result); - self::assertStringNotContainsString('é', $result); - - // Unicode boundary characters - $boundary = "\u{00FF}\u{0100}"; // End of Latin-1, start of Latin Extended-A - $result = StringManipulation::removeAccents($boundary); - self::assertNotEmpty($result); - - // Maximum string length scenarios - $maxString = str_repeat('café', 50000); - $startTime = microtime(true); - $result = StringManipulation::removeAccents($maxString); - $duration = microtime(true) - $startTime; - self::assertLessThan(5.0, $duration, 'Very large strings should be processed efficiently'); - self::assertStringContainsString('cafe', $result); - - // Repeated accent patterns - $repeatedPattern = str_repeat('éàé', 1000); - $result = StringManipulation::removeAccents($repeatedPattern); - self::assertEquals(str_repeat('eae', 1000), $result); - - // Mixed encoding attempts - $convertedString = mb_convert_encoding('café', 'ISO-8859-1', 'UTF-8'); - $mixedAttempt = 'café' . ($convertedString !== false ? $convertedString : ''); - $result = StringManipulation::removeAccents($mixedAttempt); - // Should handle gracefully - result will be a string - self::assertNotEmpty($result); - - // Stress test with all possible accented characters - $allAccents = ''; - for ($i = 192; $i <= 255; ++$i) { - if ($i !== 215 && $i !== 247) { // Skip multiplication and division signs - $converted = mb_convert_encoding('&#' . (string) $i . ';', 'UTF-8', 'HTML-ENTITIES'); - $allAccents .= ($converted !== false ? $converted : ''); - } - } - $result = StringManipulation::removeAccents($allAccents); - self::assertNotEmpty($result); - } -} +test('remove accents function', function (): void { + expect(StringManipulation::removeAccents('áéíóú'))->toBe('aeiou'); + expect(StringManipulation::removeAccents('ÁÉÍÓÚ'))->toBe('AEIOU'); + expect(StringManipulation::removeAccents('ÄëÖëÜë'))->toBe('AeOeUe'); + expect(StringManipulation::removeAccents('Niño'))->toBe('Nino'); + expect(StringManipulation::removeAccents('côte d’Ivoire'))->toBe("cote d'Ivoire"); +}); +test('remove accents function negative', function (): void { + // Passing empty string + expect(StringManipulation::removeAccents(''))->toBe(''); + + // Passing numbers + expect(StringManipulation::removeAccents('12345'))->toBe('12345'); + + // Passing special characters + expect(StringManipulation::removeAccents('!@#$%'))->toBe('!@#$%'); + + // Passing a string without accents + expect(StringManipulation::removeAccents('abcdef'))->toBe('abcdef'); +}); +test('remove accents', function (): void { + $string = 'ÀÁÂÃÄÅ'; + $result = StringManipulation::removeAccents($string); + expect($result)->toBe('AAAAAA'); +}); +test('remove accents with no accents', function (): void { + $string = 'ABCDEF'; + $result = StringManipulation::removeAccents($string); + expect($result)->toBe('ABCDEF'); +}); + +test('remove accents handles double spaces', function (): void { + // Line 232 & 233 mutations: RemoveArrayItem + // Tests that the function correctly handles the ' ' => ' ' mapping + // in the accentsReplacement array + $stringWithDoubleSpaces = 'hello world'; + $result = StringManipulation::removeAccents($stringWithDoubleSpaces); + expect($result)->toBe('hello world'); + + // Test multiple double spaces + $stringWithMultipleDoubleSpaces = 'a b c d'; + $result2 = StringManipulation::removeAccents($stringWithMultipleDoubleSpaces); + expect($result2)->toBe('a b c d'); +}); + +test('remove accents converts all A-based characters', function (): void { + // Lines 194-199 mutations: RemoveArrayItem for Á, À, Â, Ä, Å, à + expect(StringManipulation::removeAccents('Café Français'))->toBe('Cafe Francais'); + expect(StringManipulation::removeAccents('ÀÁÂÃÄÅ'))->toBe('AAAAAA'); + expect(StringManipulation::removeAccents('àáâãäå'))->toBe('aaaaaa'); +}); + +test('remove accents converts all E-based characters', function (): void { + // Lines 200-201 mutations: RemoveArrayItem for É, È, Ê, Ë + expect(StringManipulation::removeAccents('ÉÈÊË'))->toBe('EEEE'); + expect(StringManipulation::removeAccents('éèêë'))->toBe('eeee'); +}); + +test('remove accents converts all I-based characters', function (): void { + // Lines 202-203 mutations: RemoveArrayItem for Í, Ì, Î, Ï + expect(StringManipulation::removeAccents('ÍÌÎÏ'))->toBe('IIII'); + expect(StringManipulation::removeAccents('íìîï'))->toBe('iiii'); +}); + +test('remove accents converts all O-based characters', function (): void { + // Lines 204-206 mutations: RemoveArrayItem for Ó, Ò, Ô, Ö, Õ, Ø + expect(StringManipulation::removeAccents('ÓÒÔÖÕØ'))->toBe('OOOOOO'); + expect(StringManipulation::removeAccents('óòôöõø'))->toBe('oooooo'); +}); + +test('remove accents converts all U-based characters', function (): void { + // Lines 207-208 mutations: RemoveArrayItem for Ú, Ù, Û, Ü + expect(StringManipulation::removeAccents('ÚÙÛÜ'))->toBe('UUUU'); + expect(StringManipulation::removeAccents('úùûü'))->toBe('uuuu'); +}); + +test('remove accents converts special Nordic and Germanic characters', function (): void { + // Test basic umlaut conversion (Ä->A, not Ä->Ae) + expect(StringManipulation::removeAccents('Ä'))->toBe('A'); + expect(StringManipulation::removeAccents('ä'))->toBe('a'); + expect(StringManipulation::removeAccents('Ö'))->toBe('O'); + expect(StringManipulation::removeAccents('ö'))->toBe('o'); + expect(StringManipulation::removeAccents('Ü'))->toBe('U'); + expect(StringManipulation::removeAccents('ü'))->toBe('u'); + expect(StringManipulation::removeAccents('ß'))->toBe('s'); +}); + +test('remove accents converts C and N special characters', function (): void { + // Lines 219-222 mutations: RemoveArrayItem for Ç, ç, Ñ, ñ + expect(StringManipulation::removeAccents('Ç'))->toBe('C'); + expect(StringManipulation::removeAccents('ç'))->toBe('c'); + expect(StringManipulation::removeAccents('Ñ'))->toBe('N'); + expect(StringManipulation::removeAccents('ñ'))->toBe('n'); +}); + +test('remove accents converts Y special characters', function (): void { + // Lines 223-224 mutations: RemoveArrayItem for Ý, ý, ÿ + expect(StringManipulation::removeAccents('Ý'))->toBe('Y'); + expect(StringManipulation::removeAccents('ý'))->toBe('y'); + expect(StringManipulation::removeAccents('ÿ'))->toBe('y'); +}); + +test('remove accents converts special ligatures and symbols', function (): void { + // Test ligatures: Æ, æ, Œ, œ (these DO convert to double letters) + expect(StringManipulation::removeAccents('Æ'))->toBe('AE'); + expect(StringManipulation::removeAccents('æ'))->toBe('ae'); + expect(StringManipulation::removeAccents('Œ'))->toBe('OE'); + expect(StringManipulation::removeAccents('œ'))->toBe('oe'); + + // Test Eth character (Ð->D, not ð->d; lowercase ð is not in the mapping) + expect(StringManipulation::removeAccents('Ð'))->toBe('D'); + + // Test curly apostrophe conversion + expect(StringManipulation::removeAccents("côte d'Ivoire"))->toBe("cote d'Ivoire"); +}); diff --git a/tests/Unit/SearchWordsTest.php b/tests/Unit/SearchWordsTest.php index 5ea7ad5..4e81a13 100644 --- a/tests/Unit/SearchWordsTest.php +++ b/tests/Unit/SearchWordsTest.php @@ -1,302 +1,98 @@ 'hello мир', - 'Café 中文' => 'cafe 中文', - 'Test Γεια' => 'test Γεια', - - // Emoji and symbols - 'Hello 🌍 World' => 'hello 🌍 world', - 'Price $19.99' => 'price $19 99', - 'Temperature 25°C' => 'temperature 25°c', - - // Combining characters - 'café' => 'cafe', // Regular é - "cafe\u{0301}" => "cafe\u{0301}", // e + combining acute - ]; - - foreach ($unicodeTests as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input), 'Failed for input: ' . $input); - } - - // Special character patterns - $specialCharTests = [ - '!!URGENT!!' => '!!urgent!!', - '---separator---' => '---separator---', - '***important***' => 'important', - '+++plus+++' => '+++plus+++', - '|||pipe|||' => '|||pipe|||', - - // Brackets and parentheses - '[important]' => '[important]', - '(note)' => 'note', - '{data}' => 'data', - '' => '', - - // Mixed punctuation - 'Hello, World!' => 'hello world!', - 'Dr. Smith Jr.' => 'dr smith jr', - 'U.S.A.' => 'u s a', - 'Ph.D.' => 'ph d', - ]; - - foreach ($specialCharTests as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input), 'Failed for input: ' . $input); - } - - // Whitespace and formatting edge cases - $whitespaceTests = [ - " multiple spaces " => 'multiple spaces', - "\ttab\tseparated\t" => 'tab separated', - "\nnewline\nseparated\n" => "newline\nseparated", - "\rmixed\r\nlinebreaks\n" => "mixed\r\nlinebreaks", - "no\x00null\x00bytes" => "no\x00null\x00bytes", - ]; - - foreach ($whitespaceTests as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input), 'Failed for input: ' . $input); - } - } - - - /** - * Test performance and stress scenarios for searchWords. - */ - public function testSearchWordsPerformance(): void - { - // Large string with many special characters - $largeText = str_repeat('Hello-World@Test#123$Special%Characters^And&More*', 1000); - $startTime = microtime(true); - $result = StringManipulation::searchWords($largeText); - $duration = microtime(true) - $startTime; - - self::assertLessThan(1.0, $duration, 'Large string processing should be efficient'); - self::assertNotNull($result); - self::assertStringContainsString('hello-world test', $result); - self::assertStringNotContainsString('@', $result); - self::assertStringContainsString('#', $result); - - // String with many consecutive special characters - $consecutiveSpecial = str_repeat('!!@@##$$%%^^&&**(())', 500) . 'content' . str_repeat('!!@@##$$%%^^&&**(())', 500); - $startTime = microtime(true); - $result = StringManipulation::searchWords($consecutiveSpecial); - $duration = microtime(true) - $startTime; - - self::assertLessThan(0.5, $duration, 'Consecutive special characters should be handled efficiently'); - self::assertNotNull($result); - self::assertStringContainsString('content', $result); - - // Unicode stress test - $unicodeStress = str_repeat('Café-Résumé@Naïve#Zürich$München%Ålesund^Øresund&Björk', 100); - $startTime = microtime(true); - $result = StringManipulation::searchWords($unicodeStress); - $duration = microtime(true) - $startTime; - - self::assertLessThan(1.0, $duration, 'Unicode processing should be efficient'); - self::assertNotNull($result); - self::assertStringContainsString('cafe-resume naive', $result); - } - - - /** - * Test negative flow scenarios for searchWords. - */ - public function testSearchWordsAdvancedNegativeFlow(): void - { - // Binary data and control characters - $binaryData = "\x00\x01\x02hello\x03\x04\x05world\x06\x07\x08"; - $result = StringManipulation::searchWords($binaryData); - self::assertNotNull($result); - self::assertStringContainsString('hello', $result); - self::assertStringContainsString('world', $result); - - // Malformed Unicode sequences - $malformedUtf8 = "hello\xFF\xFEworld"; - $result = StringManipulation::searchWords($malformedUtf8); - self::assertNotNull($result); - self::assertStringContainsString('hello', $result); - self::assertStringContainsString('world', $result); - - // Very long individual "words" (no spaces) - $longWord = str_repeat('a', 10000); - $result = StringManipulation::searchWords($longWord); - self::assertEquals(strtolower($longWord), $result); - - // String with only special characters - $onlySpecial = '!@#$%^&*()[]{}|\\:";\'<>?,./-_+=~`'; - $result = StringManipulation::searchWords($onlySpecial); - self::assertNotNull($result); - self::assertStringNotContainsString('@', $result); - // Note: # is actually preserved in the output - self::assertStringContainsString('#', $result); - - // Mixed encoding attempts - $convertedString = mb_convert_encoding('café', 'ISO-8859-1', 'UTF-8'); - $mixedEncoding = 'hello' . ($convertedString !== false ? $convertedString : '') . 'world'; - $result = StringManipulation::searchWords($mixedEncoding); - self::assertNotNull($result); - - // Edge case with Mac/Mc prefixes in various contexts - $macTests = [ - 'MacArthur-MacDonald' => 'macarthur-macdonald', - 'mcbride@mcdonald.com' => 'mcbride mc donald com', - 'Mac&Cheese' => 'mac&cheese', - 'Mc#Test' => 'mc#test', - ]; - - foreach ($macTests as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input), 'Failed for Mac/Mc test: ' . $input); - } - } - - - /** - * Test real-world scenarios and complex inputs. - */ - public function testSearchWordsRealWorldScenarios(): void - { - // Email addresses - $emails = [ - 'contact@example.com' => 'contact example com', - 'user.name+tag@domain.co.uk' => 'user name+tag domain co uk', - 'test123@sub.domain.org' => 'test123 sub domain org', - ]; - - foreach ($emails as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input)); - } - - // URLs - $urls = [ - 'https://www.example.com/path' => 'https www example com path', - 'ftp://files.domain.org:8080' => 'ftp files domain org 8080', - 'http://sub-domain.test.io' => 'http sub-domain test io', - ]; - - foreach ($urls as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input)); - } - - // File paths - $paths = [ - '/home/user/documents/file.txt' => 'home user documents file txt', - 'C:\\Users\\Name\\Desktop\\test.doc' => 'c users name desktop test doc', - './relative/path/file.php' => 'relative path file php', - ]; - - foreach ($paths as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input)); - } - - // Social media and hashtags - $social = [ - '#hashtag @username' => '#hashtag username', - '@mention #tag123' => 'mention #tag123', - 'Follow @user_name for #updates!' => 'follow user name for #updates!', - ]; - - foreach ($social as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input)); - } - - // Technical content - $technical = [ - 'function_name($param1, $param2)' => 'function name $param1 $param2', - 'array["key"] = value;' => 'array[ key ] = value;', - 'object.method().property' => 'object method property', - 'SELECT * FROM table_name WHERE id=123' => 'select from table name where id=123', - ]; - foreach ($technical as $input => $expected) { - self::assertEquals($expected, StringManipulation::searchWords($input)); - } - } -} +test('search words function', function (): void { + // Basic tests + expect(StringManipulation::searchWords('MacDonald'))->toBe('macdonald'); + expect(StringManipulation::searchWords('Hello World'))->toBe('hello world'); + expect(StringManipulation::searchWords('Hèllo Wørld'))->toBe('hello world'); + expect(StringManipulation::searchWords('a/b/c'))->toBe('a b c'); + expect(StringManipulation::searchWords('hello_world'))->toBe('hello world'); +}); +test('search words function negative', function (): void { + // Passing null + expect(StringManipulation::searchWords(null))->toBeNull(); + + // Passing numbers + expect(StringManipulation::searchWords('12345'))->toBe('12345'); + + // Passing special characters + expect(StringManipulation::searchWords('!@#$%'))->toBe('! #$%'); + + // Passing strings with extra spaces + expect(StringManipulation::searchWords(' hello world '))->toBe('hello world'); + + // Passing strings with mixed special characters and extra spaces + expect(StringManipulation::searchWords('hello / world'))->toBe('hello world'); + expect(StringManipulation::searchWords(' hello / world '))->toBe('hello world'); +}); +test('search words with unlisted special characters', function (): void { + $words = '[Hello*World!]'; + $result = StringManipulation::searchWords($words); + expect($result)->toBe('[hello world!]'); +}); + +test('search words converts all special characters to spaces', function (): void { + // Test each character from the searchChars array: + // {, }, (, ), /, \, @, :, ", ?, ,, ., _ + + // Characters that were NOT being tested (6 surviving mutations): + expect(StringManipulation::searchWords('hello}world'))->toBe('hello world'); + expect(StringManipulation::searchWords('hello)world'))->toBe('hello world'); + expect(StringManipulation::searchWords('hello\\world'))->toBe('hello world'); + expect(StringManipulation::searchWords('hello:world'))->toBe('hello world'); + expect(StringManipulation::searchWords('hello,world'))->toBe('hello world'); + expect(StringManipulation::searchWords('hello.world'))->toBe('hello world'); +}); + +test('search words converts double quote to space', function (): void { + // Line 90 mutation: RemoveArrayItem for " (double quote) + expect(StringManipulation::searchWords('hello"world'))->toBe('hello world'); + expect(StringManipulation::searchWords('"quoted"'))->toBe('quoted'); + expect(StringManipulation::searchWords('say "hello" world'))->toBe('say hello world'); +}); + +test('search words handles empty string correctly', function (): void { + // Line 100 mutation: EmptyStringToNotEmpty + // Test that preg_replace returning null is handled correctly + expect(StringManipulation::searchWords(''))->toBe(''); +}); + +test('search words converts question mark to space', function (): void { + // Line 90 mutation: RemoveArrayItem for ? (question mark) + expect(StringManipulation::searchWords('hello?world'))->toBe('hello world'); + expect(StringManipulation::searchWords('what?where?'))->toBe('what where'); + expect(StringManipulation::searchWords('question?'))->toBe('question'); +}); + +test('search words converts at symbol to space', function (): void { + // Line 90 mutation: RemoveArrayItem for @ (at symbol) + expect(StringManipulation::searchWords('hello@world'))->toBe('hello world'); + expect(StringManipulation::searchWords('user@domain'))->toBe('user domain'); +}); + +test('search words converts opening parenthesis to space', function (): void { + // Line 90 mutation: RemoveArrayItem for ( (opening parenthesis) + expect(StringManipulation::searchWords('hello(world'))->toBe('hello world'); + expect(StringManipulation::searchWords('(test'))->toBe('test'); +}); + +test('search words converts opening brace to space', function (): void { + // Line 90 mutation: RemoveArrayItem for { (opening brace) + expect(StringManipulation::searchWords('hello{world'))->toBe('hello world'); + expect(StringManipulation::searchWords('{test}'))->toBe('test'); +}); + +test('search words converts forward slash to space', function (): void { + // Line 90 mutation: RemoveArrayItem for / (forward slash) + expect(StringManipulation::searchWords('hello/world'))->toBe('hello world'); + expect(StringManipulation::searchWords('path/to/file'))->toBe('path to file'); +}); + +test('search words converts underscore to space', function (): void { + // Line 90 mutation: RemoveArrayItem for _ (underscore) + expect(StringManipulation::searchWords('hello_world'))->toBe('hello world'); + expect(StringManipulation::searchWords('snake_case_name'))->toBe('snake case name'); +}); diff --git a/tests/Unit/StrReplaceTest.php b/tests/Unit/StrReplaceTest.php index 80d428f..2223758 100644 --- a/tests/Unit/StrReplaceTest.php +++ b/tests/Unit/StrReplaceTest.php @@ -1,320 +1,117 @@ - */ - private const array SEARCH = [ - 'H', - 'e', - 'W', - ]; - - /** - * @var array - */ - private const array REPLACE = [ - 'h', - 'x', - 'w', - ]; - - private const string SUBJECT = 'Hello World'; - - - public function testStrReplaceBasicFunctionality(): void - { - // Test with not found search - $result = StringManipulation::strReplace('pineapple', 'banana', self::LOVE_APPLE); - self::assertEquals(self::LOVE_APPLE, $result); - - // Basic test - self::assertEquals('b', StringManipulation::strReplace('a', 'b', 'a')); - - // Replace multiple characters - self::assertEquals('helloworld', StringManipulation::strReplace(['H', 'W'], ['h', 'w'], 'Helloworld')); - - // Replace multiple occurrences of a single character - self::assertEquals('hxllo world', StringManipulation::strReplace('e', 'x', 'hello world')); - self::assertEquals('hxllo world', StringManipulation::strReplace(self::SEARCH, self::REPLACE, self::SUBJECT)); - - // Basic replacement test - $result = StringManipulation::strReplace('apple', 'banana', self::LOVE_APPLE); - self::assertEquals('I love banana.', $result); - } - - - /** - * Test that specifically targets the single character optimisation path. - * This kills the IncrementInteger mutation by ensuring behaviour is different - * when search string length is exactly 1. - */ - public function testSingleCharacterOptimisation(): void - { - // Test with a single character (should use strtr optimisation). - $result1 = StringManipulation::strReplace('a', 'z', 'banana'); - self::assertSame('bznznz', $result1); - - // Test with a two-character string (should use str_replace). - $result2 = StringManipulation::strReplace('an', 'z', 'banana'); - self::assertSame('bzza', $result2); - - // This verifies the behaviour difference - if the mutation changes the length check. - // from === 1 to === 2, both calls would produce the same behaviour, and this test would fail. - } - - /** - * Test that specifically targets the distinction between single character and non-single character. - * This kills the Identical mutation that changes === 1 to !== 1 - */ - public function testSingleCharacterVsMultipleCharacter(): void - { - // Create a scenario where strtr and str_replace have observable differences. - - // Case 1: Using a single character replacement (should use strtr). - $subject = 'abababa'; - $result1 = StringManipulation::strReplace('a', 'c', $subject); - - // Case 2: Using an array with equivalent replacements (should use str_replace). - $result2 = StringManipulation::strReplace(['a'], ['c'], $subject); - - // Both should produce the same result despite taking different code paths. - self::assertSame('cbcbcbc', $result1); - self::assertSame($result1, $result2); - - // This next test specifically looks at behaviour that would be different. - // if the optimisation wasn't properly working. - - // Using overlapping replacements, the order matters in str_replace but not in strtr. - $complex = 'abcabc'; - - // Directly using strtr for comparison. - $expected = strtr($complex, ['a' => 'z', 'z' => 'y']); - - // Using our optimised function which should handle this the same way. - $actual = StringManipulation::strReplace('a', 'z', $complex); - self::assertSame('zbczbc', $actual); - self::assertSame($expected, $actual); - } - - /** - * Edge case test that verifies the empty string optimisation - */ - public function testEmptyStringOptimisation(): void - { - // Test that empty subject returns empty string immediately. - $result = StringManipulation::strReplace('a', 'b', ''); - self::assertSame('', $result); - - // Test that empty search/replace with non-empty subject works correctly. - $result = StringManipulation::strReplace('', 'x', 'abc'); - self::assertSame('abc', $result); - } - - - /** - * Test that verifies both conditions are required for single character optimisation. - * This targets the LogicalAnd mutations in the strReplace function. - */ - public function testSingleCharacterOptimisationRequiresBothConditions(): void - { - // Use variables to avoid Psalm's literal string analysis - $testString = 'banana'; - $searchChar = 'a'; - $replaceChar = 'z'; - $expectedResult = 'bznznz'; - - // Case 1: Array search with single character - should NOT use strtr optimisation - $result1 = StringManipulation::strReplace([$searchChar], $replaceChar, $testString); - self::assertSame($expectedResult, $result1); - - // Case 2: Array types - should NOT use strtr optimisation - $result2 = StringManipulation::strReplace([$searchChar], [$replaceChar], $testString); - self::assertSame($expectedResult, $result2); - - // Case 3: Both string types but length > 1 - should NOT use strtr optimisation - $result3 = StringManipulation::strReplace('an', $replaceChar, $testString); - self::assertSame('bzza', $result3); - - // Case 4: Both conditions met - SHOULD use strtr optimisation - $result4 = StringManipulation::strReplace($searchChar, $replaceChar, $testString); - self::assertSame($expectedResult, $result4); - - // Case 5: Test empty string case - should NOT use strtr optimisation - $result5 = StringManipulation::strReplace('', $replaceChar, $testString); - self::assertSame($testString, $result5); - - // Case 6: Test longer string case - should NOT use strtr optimisation - $result6 = StringManipulation::strReplace('ban', 'can', $testString); - self::assertSame('canana', $result6); - - // All single-character replacements tested above should produce consistent results - // The individual assertions above verify that different code paths work correctly - } - - - /** - * Test comprehensive array-based string replacements. - */ - public function testArrayReplacements(): void - { - // Multiple search/replace arrays - $searches = ['cat', 'dog', 'bird']; - $replacements = ['feline', 'canine', 'avian']; - $text = 'The cat, dog, and bird are animals.'; - $expected = 'The feline, canine, and avian are animals.'; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $text)); - - // Array with overlapping matches - $searches = ['ab', 'bc', 'ca']; - $replacements = ['X', 'Y', 'Z']; - $text = 'abcabc'; - $result = StringManipulation::strReplace($searches, $replacements, $text); - // Actual behaviour: 'ab' -> 'X', then 'bc' -> 'Y' doesn't match because 'b' is gone - self::assertEquals('XcXc', $result); - - // Arrays with different character lengths - $searches = ['a', 'bb', 'ccc']; - $replacements = ['AAA', 'B', 'c']; - $text = 'a bb ccc'; - $expected = 'AAA B c'; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $text)); - - // Empty replacements - $searches = ['remove', 'delete', 'erase']; - $replacements = ['', '', '']; - $text = 'remove this, delete that, erase everything'; - $expected = ' this, that, everything'; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $text)); - } - - - /** - * Test case-sensitive string replacements. - */ - public function testCaseSensitiveReplacements(): void - { - // Basic case sensitivity - $text = 'Hello hello HELLO'; - self::assertEquals('Hi hello HELLO', StringManipulation::strReplace('Hello', 'Hi', $text)); - self::assertEquals('Hello Hi HELLO', StringManipulation::strReplace('hello', 'Hi', $text)); - self::assertEquals('Hello hello HI', StringManipulation::strReplace('HELLO', 'HI', $text)); - - // Mixed case arrays - $searches = ['Cat', 'DOG', 'bIrD']; - $replacements = ['Feline', 'CANINE', 'AvIaN']; - $text = 'Cat DOG bIrD cat dog bird'; - $expected = 'Feline CANINE AvIaN cat dog bird'; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $text)); - } - - - /** - * Test string replacements with special characters. - */ - public function testSpecialCharacterReplacements(): void - { - // Replace special characters - $text = 'Hello@World#Test$Example%Done'; - self::assertEquals('Hello_World#Test$Example%Done', StringManipulation::strReplace('@', '_', $text)); - self::assertEquals('Hello@World_Test$Example%Done', StringManipulation::strReplace('#', '_', $text)); - - // Multiple special character replacements - $searches = ['@', '#', '$', '%']; - $replacements = ['_AT_', '_HASH_', '_DOLLAR_', '_PERCENT_']; - $expected = 'Hello_AT_World_HASH_Test_DOLLAR_Example_PERCENT_Done'; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $text)); - - // Unicode special characters - $unicodeText = 'Café→Restaurant←Menu'; - self::assertEquals('Café_Restaurant←Menu', StringManipulation::strReplace('→', '_', $unicodeText)); - self::assertEquals('Café→Restaurant_Menu', StringManipulation::strReplace('←', '_', $unicodeText)); - } - - - /** - * Test string replacements with numbers. - */ - public function testNumberReplacements(): void - { - // Replace numbers - $text = 'Version 1.2.3 released on 2023-09-06'; - self::assertEquals('Version X.2.3 released on 2023-09-06', StringManipulation::strReplace('1', 'X', $text)); - - // Replace multiple numbers - $searches = ['1', '2', '3']; - $replacements = ['ONE', 'TWO', 'THREE']; - $expected = 'Version ONE.TWO.THREE released on TWO0TWOTHREE-09-06'; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $text)); - - // Replace number patterns - $dateText = '2023-09-06 14:30:15'; - self::assertEquals('XXXX-09-06 14:30:15', StringManipulation::strReplace('2023', 'XXXX', $dateText)); - self::assertEquals('2023-XX-06 14:30:15', StringManipulation::strReplace('09', 'XX', $dateText)); - } - - - /** - * Test performance, whitespace and real-world scenarios. - */ - public function testAdvancedReplacementScenarios(): void - { - // Performance: Large text with multiple replacements - $largeText = str_repeat('The quick brown fox jumps over the lazy dog. ', 100); - $result = StringManipulation::strReplace('fox', 'cat', $largeText); - self::assertStringContainsString('cat', $result); - self::assertStringNotContainsString('fox', $result); - - // Performance: Many small replacements - $text = str_repeat('abcdefghijklmnopqrstuvwxyz', 10); - $searches = ['a', 'e', 'i', 'o', 'u']; - $replacements = ['A', 'E', 'I', 'O', 'U']; - $result = StringManipulation::strReplace($searches, $replacements, $text); - self::assertStringContainsString('A', $result); - self::assertStringNotContainsString('a', $result); - - // Whitespace: Replace different types of whitespace - $text = "Line1\tTab\nNewline\rCarriageReturn Line2"; - self::assertEquals("Line1 Tab\nNewline\rCarriageReturn Line2", StringManipulation::strReplace("\t", ' ', $text)); - - // Whitespace: Normalise all whitespace - $searches = ["\t", "\n", "\r"]; - $replacements = [' ', ' ', ' ']; - $expected = "Line1 Tab Newline CarriageReturn Line2"; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $text)); - - // Real-world: HTML entity replacement - $htmlText = 'Café & Restaurant "Menu"'; - $searches = ['é', '&', '"']; - $replacements = ['é', '&', '"']; - $expected = 'Café & Restaurant "Menu"'; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $htmlText)); - - // Real-world: URL slug creation - $title = 'How to Learn PHP: A Complete Guide for Beginners!'; - $searches = [' ', ':', '!']; - $replacements = ['-', '', '']; - $expected = 'How-to-Learn-PHP-A-Complete-Guide-for-Beginners'; - self::assertEquals($expected, StringManipulation::strReplace($searches, $replacements, $title)); - // Real-world: File path normalisation - $windowsPath = 'C:\\Users\\Name\\Documents\\File.txt'; - $unixPath = 'C:/Users/Name/Documents/File.txt'; - self::assertEquals($unixPath, StringManipulation::strReplace('\\', '/', $windowsPath)); - } -} +test('str replace with not found search', function (): void { + $result = StringManipulation::strReplace('pineapple', 'banana', 'I love apple.'); + expect($result)->toBe('I love apple.'); +}); +test('str replace function', function (): void { + // Basic test. + expect(StringManipulation::strReplace('a', 'b', 'a'))->toBe('b'); + + // Replace multiple characters. + expect(StringManipulation::strReplace(['H', 'W'], ['h', 'w'], 'Helloworld'))->toBe('helloworld'); + + // Replace multiple occurrences of a single character. + expect(StringManipulation::strReplace('e', 'x', 'hello world'))->toBe('hxllo world'); + expect(StringManipulation::strReplace(['H', 'e', 'W'], ['h', 'x', 'w'], 'Hello World'))->toBe('hxllo world'); +}); +test('str replace', function (): void { + $result = StringManipulation::strReplace('apple', 'banana', 'I love apple.'); + expect($result)->toBe('I love banana.'); +}); +test('single character optimization', function (): void { + // Test with a single character (should use strtr optimization). + $result1 = StringManipulation::strReplace('a', 'z', 'banana'); + expect($result1)->toBe('bznznz'); + + // Test with a two-character string (should use str_replace). + $result2 = StringManipulation::strReplace('an', 'z', 'banana'); + expect($result2)->toBe('bzza'); + + // This verifies the behavior difference - if the mutation changes the length check. + // from === 1 to === 2, both calls would produce the same behavior, and this test would fail. +}); +test('single character vs multiple character', function (): void { + // Create a scenario where strtr and str_replace have observable differences. + // Case 1: Using a single character replacement (should use strtr). + $subject = 'abababa'; + $result1 = StringManipulation::strReplace('a', 'c', $subject); + + // Case 2: Using an array with equivalent replacements (should use str_replace). + $result2 = StringManipulation::strReplace(['a'], ['c'], $subject); + + // Both should produce the same result despite taking different code paths. + expect($result1)->toBe('cbcbcbc'); + expect($result2)->toBe($result1); + + // This next test specifically looks at behavior that would be different. + // if the optimization wasn't properly working. + // Using overlapping replacements, the order matters in str_replace but not in strtr. + $complex = 'abcabc'; + + // Directly using strtr for comparison. + $expected = strtr($complex, ['a' => 'z', 'z' => 'y']); + + // Using our optimized function which should handle this the same way. + $actual = StringManipulation::strReplace('a', 'z', $complex); + expect($actual)->toBe('zbczbc'); + expect($actual)->toBe($expected); +}); +test('empty string optimization', function (): void { + // Line 276 mutation: RemoveEarlyReturn + // Test that empty subject returns empty string immediately + $result = StringManipulation::strReplace('a', 'b', ''); + expect($result)->toBe(''); + + // Test that empty search/replace with non-empty subject works correctly + $result = StringManipulation::strReplace('', 'x', 'abc'); + expect($result)->toBe('abc'); +}); + +test('single character optimization mutations', function (): void { + // Line 280 mutations: IdenticalToNotIdentical, BooleanAndToBooleanOr, DecrementInteger, IncrementInteger + // Line 281 mutation: RemoveEarlyReturn + // These test the optimization path: is_string($search) && is_string($replace) && strlen($search) === 1 + + // All three conditions must be true: + // 1. search is string (not array) + // 2. replace is string (not array) + // 3. search length is exactly 1 + + // Test case where search is array (first condition false) + $arraySearch = StringManipulation::strReplace(['a'], ['b'], 'apple'); + expect($arraySearch)->toBe('bpple'); + + // Test case where search length is 0 (third condition false) + $zeroLength = StringManipulation::strReplace('', 'x', 'apple'); + expect($zeroLength)->toBe('apple'); + + // Test case where search length is 2 (third condition false - not === 1) + $twoChars = StringManipulation::strReplace('pp', 'tt', 'apple'); + expect($twoChars)->toBe('attle'); + + // Test case where ALL conditions are true (optimization path) + $singleChar = StringManipulation::strReplace('p', 't', 'apple'); + expect($singleChar)->toBe('attle'); +}); + +test('single character optimization uses correct path', function (): void { + // Line 280 mutations specifically test the strlen($search) === 1 check + // DecrementInteger would change it to === 0 + // IncrementInteger would change it to === 2 + + // With length === 1 (correct), this should use strtr optimization + $len1 = StringManipulation::strReplace('x', 'y', 'xxx'); + expect($len1)->toBe('yyy'); + + // With length === 0 (if decremented), empty search would not match + $len0 = StringManipulation::strReplace('', 'y', 'xxx'); + expect($len0)->toBe('xxx'); // Should not change + + // With length === 2 (if incremented), this would use str_replace instead + $len2 = StringManipulation::strReplace('xx', 'yy', 'xxx'); + expect($len2)->toBe('yyx'); // Different result than single char replacement +}); diff --git a/tests/Unit/TrimTest.php b/tests/Unit/TrimTest.php index 3cd2716..6c26428 100644 --- a/tests/Unit/TrimTest.php +++ b/tests/Unit/TrimTest.php @@ -1,204 +1,54 @@ > */ -final class TrimTest extends TestCase -{ - private const string DEFAULT_TRIM_CHARACTERS = " \t\n\r\0\x0B"; - - - /** - * @return array> - */ - public static function trimDataProvider(): array - { - return array_merge( - self::getBasicTrimCases(), - self::getAdvancedTrimCases(), - self::getSpecialTrimCases(), - ); - } - - /** - * @return array> - */ - private static function getBasicTrimCases(): array - { - return [ - // Basic tests - [' hello ', self::DEFAULT_TRIM_CHARACTERS, 'hello'], - ["\thello\t", self::DEFAULT_TRIM_CHARACTERS, 'hello'], - ["\nhello\n", self::DEFAULT_TRIM_CHARACTERS, 'hello'], - // Tests with custom characters - ['[hello]', '[]', 'hello'], - ['(hello)', '()', 'hello'], - // Tests with empty strings - ['', self::DEFAULT_TRIM_CHARACTERS, ''], - // Tests with no characters to trim - ['hello', 'z', 'hello'], - // Multiple consecutive whitespace - [' hello ', self::DEFAULT_TRIM_CHARACTERS, 'hello'], - ["\t\t\thello\t\t\t", self::DEFAULT_TRIM_CHARACTERS, 'hello'], - ["\n\r\n\rhello\n\r\n\r", self::DEFAULT_TRIM_CHARACTERS, 'hello'], - // Mixed whitespace types - [" \t\n\rhello \t\n\r", self::DEFAULT_TRIM_CHARACTERS, 'hello'], - ]; - } - - /** - * @return array> - */ - private static function getAdvancedTrimCases(): array - { - return [ - // Unicode whitespace characters - ["\u{00A0}hello\u{00A0}", "\u{00A0}", 'hello'], - ["\u{2000}hello\u{2000}", "\u{2000}", 'hello'], - // Complex custom character sets - ['***hello***', '*', 'hello'], - ['abcdefghelloabcdefg', 'abcdefg', 'hello'], - ['.,;!hello.,;!', '.,;!', 'hello'], - // Only trim characters - [' ', self::DEFAULT_TRIM_CHARACTERS, ''], - ['***', '*', ''], - // Null bytes and special characters - ["\0hello\0", "\0", 'hello'], - ["\x0Bhello\x0B", "\x0B", 'hello'], - ]; - } - - /** - * @return array> - */ - private static function getSpecialTrimCases(): array - { - return [ - // One-sided trimming scenarios - [' hello', ' ', 'hello'], - ['hello ', ' ', 'hello'], - // Numbers with trim characters - [' 12345 ', self::DEFAULT_TRIM_CHARACTERS, '12345'], - // Long strings - [' ' . str_repeat('hello', 100) . ' ', self::DEFAULT_TRIM_CHARACTERS, str_repeat('hello', 100)], - // Multiple character trim set - ['abcXYZabc', 'abc', 'XYZ'], - ]; - } - - - /** - * @dataProvider trimDataProvider - */ - #[DataProvider('trimDataProvider')] - public function testTrim(string $input, string $characters, mixed $expected): void - { - self::assertEquals($expected, StringManipulation::trim($input, $characters)); - } - - - /** - * Test negative flow scenarios for trim function. - */ - public function testTrimNegativeFlow(): void - { - // Very large character set - $hugeCharSet = str_repeat('abcdefghijklmnopqrstuvwxyz', 50); - $text = 'xyz middle content abc'; - $result = StringManipulation::trim($text, $hugeCharSet); - self::assertEquals(' middle content ', $result); - - // Empty character set - should return original string - $text = ' hello world '; - self::assertEquals($text, StringManipulation::trim($text, '')); - - // Characters not present in string - $text = 'hello world'; - self::assertEquals($text, StringManipulation::trim($text, 'xyz')); - - // All characters are trim characters - $text = ' '; - self::assertEquals('', StringManipulation::trim($text, ' ')); - - // Malformed Unicode sequences (binary data) - $binaryData = "\x80\x81\x82hello\x83\x84\x85"; - $result = StringManipulation::trim($binaryData, "\x80\x81\x82\x83\x84\x85"); - self::assertEquals('hello', $result); - - // Very long string with performance implications - $longString = str_repeat('a', 10000) . 'content' . str_repeat('b', 10000); - $startTime = microtime(true); - $result = StringManipulation::trim($longString, 'ab'); - $duration = microtime(true) - $startTime; - self::assertEquals('content', $result); - self::assertLessThan(1.0, $duration, 'Trim operation should complete within reasonable time'); - - // Unicode edge cases - invalid UTF-8 - $invalidUtf8 = "\xFF\xFE" . 'hello' . "\xFF\xFE"; - $result = StringManipulation::trim($invalidUtf8, "\xFF\xFE"); - self::assertEquals('hello', $result); - - // Null bytes in character set - $text = "\0\x01hello\x01\0"; - $result = StringManipulation::trim($text, "\0\x01"); - self::assertEquals('hello', $result); - - // Special regex characters in trim set - $text = '.*+hello+*.'; - $result = StringManipulation::trim($text, '.*+'); - self::assertEquals('hello', $result); - } - - - /** - * Test edge cases and boundary conditions. - */ - public function testTrimEdgeCases(): void - { - // Single character string with trim character - self::assertEquals('', StringManipulation::trim('a', 'a')); - - // Single character string without trim character - self::assertEquals('b', StringManipulation::trim('b', 'a')); - - // String with only whitespace variations - $whitespaceOnly = " \t\n\r\0\x0B"; - self::assertEquals('', StringManipulation::trim($whitespaceOnly, self::DEFAULT_TRIM_CHARACTERS)); - - // Mixed control characters - $controlChars = "\x01\x02\x03\x04\x05"; - $text = $controlChars . 'content' . $controlChars; - $result = StringManipulation::trim($text, $controlChars); - self::assertEquals('content', $result); - - // Unicode boundary characters - $text = "\u{00A0}\u{2000}content\u{2000}\u{00A0}"; - $result = StringManipulation::trim($text, "\u{00A0}\u{2000}"); - self::assertEquals('content', $result); - - // Overlapping character ranges - $text = 'abcdefg'; - $result = StringManipulation::trim($text, 'abcgfe'); - self::assertEquals('d', $result); - - // Maximum length character set - $allAscii = ''; - for ($i = 32; $i <= 126; ++$i) { - $allAscii .= chr($i); - } - - $text = 'Hello World!'; - $result = StringManipulation::trim($text, $allAscii); - self::assertEquals('', $result); - } -} +dataset('trimDataProvider', fn(): array => [ + + // Basic tests + [ + ' hello ', + " \t\n\r\0\x0B", + 'hello', + ], + [ + "\thello\t", + " \t\n\r\0\x0B", + 'hello', + ], + [ + "\nhello\n", + " \t\n\r\0\x0B", + 'hello', + ], + // Tests with custom characters + [ + '[hello]', + '[]', + 'hello', + ], + [ + '(hello)', + '()', + 'hello', + ], + // Tests with empty strings + [ + '', + " \t\n\r\0\x0B", + '', + ], + // Tests with no characters to trim + [ + 'hello', + 'z', + 'hello', + ], +]); +test('trim', function (string $input, string $characters, mixed $expected): void { + expect(StringManipulation::trim($input, $characters))->toBe($expected); + +})->with('trimDataProvider'); diff --git a/tests/Unit/UppercaseAccentMappingBugFixTest.php b/tests/Unit/UppercaseAccentMappingBugFixTest.php index a15bb22..82f166c 100644 --- a/tests/Unit/UppercaseAccentMappingBugFixTest.php +++ b/tests/Unit/UppercaseAccentMappingBugFixTest.php @@ -15,10 +15,9 @@ * FIX: Apply strtolower() to REMOVE_ACCENTS_TO values * * @internal - * - * @covers \MarjovanLier\StringManipulation\StringManipulation::searchWords - * @covers \MarjovanLier\StringManipulation\StringManipulation::removeAccents */ +#[\PHPUnit\Framework\Attributes\CoversMethod(\MarjovanLier\StringManipulation\StringManipulation::class, 'searchWords')] +#[\PHPUnit\Framework\Attributes\CoversMethod(\MarjovanLier\StringManipulation\StringManipulation::class, 'removeAccents')] final class UppercaseAccentMappingBugFixTest extends TestCase { /** @@ -46,10 +45,10 @@ private function resetStaticCache(): void { $reflectionClass = new ReflectionClass(StringManipulation::class); - $reflectionProperty = $reflectionClass->getProperty('SEARCH_WORDS_MAPPING'); + $reflectionProperty = $reflectionClass->getProperty('searchWordsMapping'); $reflectionProperty->setValue(null, []); - $accentsReplacement = $reflectionClass->getProperty('ACCENTS_REPLACEMENT'); + $accentsReplacement = $reflectionClass->getProperty('accentsReplacement'); $accentsReplacement->setValue(null, []); } diff --git a/tests/Unit/Utf8AnsiTest.php b/tests/Unit/Utf8AnsiTest.php index 032f5c2..89a8495 100644 --- a/tests/Unit/Utf8AnsiTest.php +++ b/tests/Unit/Utf8AnsiTest.php @@ -1,371 +1,86 @@ */ -final class Utf8AnsiTest extends TestCase -{ - /** - * @var array - */ - private const array UTF8_TO_ANSI_MAP = [ - '\u00c0' => 'À', - '\u00c1' => 'Á', - '\u00c2' => 'Â', - '\u00c3' => 'Ã', - '\u00c4' => 'Ä', - '\u00c5' => 'Å', - '\u00c6' => 'Æ', - '\u00c7' => 'Ç', - '\u00c8' => 'È', - '\u00c9' => 'É', - '\u00ca' => 'Ê', - '\u00cb' => 'Ë', - '\u00cc' => 'Ì', - '\u00cd' => 'Í', - '\u00ce' => 'Î', - '\u00cf' => 'Ï', - '\u00d1' => 'Ñ', - '\u00d2' => 'Ò', - '\u00d3' => 'Ó', - '\u00d4' => 'Ô', - '\u00d5' => 'Õ', - '\u00d6' => 'Ö', - '\u00d8' => 'Ø', - '\u00d9' => 'Ù', - '\u00da' => 'Ú', - '\u00db' => 'Û', - '\u00dc' => 'Ü', - '\u00dd' => 'Ý', - '\u00df' => 'ß', - '\u00e0' => 'à', - '\u00e1' => 'á', - '\u00e2' => 'â', - '\u00e3' => 'ã', - '\u00e4' => 'ä', - '\u00e5' => 'å', - '\u00e6' => 'æ', - '\u00e7' => 'ç', - '\u00e8' => 'è', - '\u00e9' => 'é', - '\u00ea' => 'ê', - '\u00eb' => 'ë', - '\u00ec' => 'ì', - '\u00ed' => 'í', - '\u00ee' => 'î', - '\u00ef' => 'ï', - '\u00f0' => 'ð', - '\u00f1' => 'ñ', - '\u00f2' => 'ò', - '\u00f3' => 'ó', - '\u00f4' => 'ô', - '\u00f5' => 'õ', - '\u00f6' => 'ö', - '\u00f8' => 'ø', - '\u00f9' => 'ù', - '\u00fa' => 'ú', - '\u00fb' => 'û', - '\u00fc' => 'ü', - '\u00fd' => 'ý', - '\u00ff' => 'ÿ', - ]; - - - public function testUtf8Ansi(): void - { - // This represents the UTF-8 encoded character 'À' - $string = '\u00c0'; - $result = StringManipulation::utf8Ansi($string); - self::assertEquals('À', $result); - } - - - /** - * Test the utf8Ansi function. - */ - public function testUtf8AnsiFunction(): void - { - foreach (self::UTF8_TO_ANSI_MAP as $utf8 => $ansi) { - self::assertEquals($ansi, StringManipulation::utf8Ansi($utf8)); - } - - // Test an empty string - self::assertEquals('', StringManipulation::utf8Ansi('')); - - // Test null input - self::assertEquals('', StringManipulation::utf8Ansi(null)); - } - - - public function testUtf8AnsiWithInvalidCharacter(): void - { - // Invalid UTF-8 encoded character - $string = '\uZZZZ'; - $result = StringManipulation::utf8Ansi($string); - self::assertEquals($string, $result); - } - - - /** - * Test UTF-8 to ANSI conversion with multiple characters. - */ - public function testUtf8AnsiMultipleCharacters(): void - { - // Test string with multiple UTF-8 characters - $utf8String = '\u00c0\u00e9\u00ef\u00f1\u00fc'; - $expectedAnsi = 'Àéïñü'; - self::assertEquals($expectedAnsi, StringManipulation::utf8Ansi($utf8String)); - - // Test mixed content with normal ASCII and UTF-8 - $mixedString = 'Hello \u00c0\u00e9\u00ef\u00f1\u00fc World'; - $expectedMixed = 'Hello Àéïñü World'; - self::assertEquals($expectedMixed, StringManipulation::utf8Ansi($mixedString)); - - // Test uppercase UTF-8 characters - $uppercaseString = '\u00c0\u00c1\u00c2\u00c3\u00c4\u00c5'; - $expectedUppercase = 'ÀÁÂÃÄÅ'; - self::assertEquals($expectedUppercase, StringManipulation::utf8Ansi($uppercaseString)); - - // Test lowercase UTF-8 characters - $lowercaseString = '\u00e0\u00e1\u00e2\u00e3\u00e4\u00e5'; - $expectedLowercase = 'àáâãäå'; - self::assertEquals($expectedLowercase, StringManipulation::utf8Ansi($lowercaseString)); - } - - - /** - * Test UTF-8 to ANSI conversion with real-world scenarios. - */ - public function testUtf8AnsiRealWorldScenarios(): void - { - // French text - $frenchText = 'Caf\u00e9 r\u00e9staurant \u00e0 Paris'; - $expectedFrench = 'Café réstaurant à Paris'; - self::assertEquals($expectedFrench, StringManipulation::utf8Ansi($frenchText)); - - // German text - $germanText = 'M\u00fcnchen ist sch\u00f6n'; - $expectedGerman = 'München ist schön'; - self::assertEquals($expectedGerman, StringManipulation::utf8Ansi($germanText)); - - // Spanish text - $spanishText = 'Ma\u00f1ana ser\u00e1 otro d\u00eda'; - $expectedSpanish = 'Mañana será otro día'; - self::assertEquals($expectedSpanish, StringManipulation::utf8Ansi($spanishText)); - - // Portuguese text - $portugueseText = 'N\u00e3o h\u00e1 solu\u00e7\u00e3o'; - $expectedPortuguese = 'Não há solução'; - self::assertEquals($expectedPortuguese, StringManipulation::utf8Ansi($portugueseText)); - - // Nordic text - $nordicText = '\u00c5\u00e6\u00f8 \u00c6\u00d8\u00c5'; - $expectedNordic = 'Åæø ÆØÅ'; - self::assertEquals($expectedNordic, StringManipulation::utf8Ansi($nordicText)); - } - - - /** - * Test UTF-8 conversion with numbers and symbols. - */ - public function testUtf8AnsiWithNumbersAndSymbols(): void - { - // Text with numbers - $numberText = 'Address: 123 Rue de la Paix, 75001 Paris, France'; - self::assertEquals($numberText, StringManipulation::utf8Ansi($numberText)); - - // Text with symbols - $symbolText = 'Price: $29.99 (15% off)'; - self::assertEquals($symbolText, StringManipulation::utf8Ansi($symbolText)); - - // Mixed UTF-8 with numbers and symbols - $mixedText = 'Conna\u00eetre: \u20ac19.99 (r\u00e9duction 15%)'; - $expectedMixed = 'Connaître: \u20ac19.99 (réduction 15%)'; - self::assertEquals($expectedMixed, StringManipulation::utf8Ansi($mixedText)); - - // Email with UTF-8 - $emailText = 'Contact: jos\u00e9@caf\u00e9.example.com'; - $expectedEmail = 'Contact: josé@café.example.com'; - self::assertEquals($expectedEmail, StringManipulation::utf8Ansi($emailText)); - } - - - /** - * Test UTF-8 conversion performance and edge cases. - */ - public function testUtf8AnsiPerformanceEdgeCases(): void - { - // Long string with many UTF-8 characters - $longString = str_repeat('\u00e9\u00e0\u00e7', 100); - $expectedLong = str_repeat('éàç', 100); - self::assertEquals($expectedLong, StringManipulation::utf8Ansi($longString)); - - // String with only ASCII characters - $asciiString = 'The quick brown fox jumps over the lazy dog.'; - self::assertEquals($asciiString, StringManipulation::utf8Ansi($asciiString)); - - // Single UTF-8 character - self::assertEquals('é', StringManipulation::utf8Ansi('\u00e9')); - self::assertEquals('Ñ', StringManipulation::utf8Ansi('\u00d1')); - - // UTF-8 characters mixed with spaces - $spacedString = '\u00e9 \u00e0 \u00e7'; - $expectedSpaced = 'é à ç'; - self::assertEquals($expectedSpaced, StringManipulation::utf8Ansi($spacedString)); - } - - - /** - * Test UTF-8 conversion with special cases. - */ - public function testUtf8AnsiSpecialCases(): void - { - // String with line breaks - $multilineString = 'Line 1\u00e9\nLine 2\u00e0\rLine 3\u00e7'; - $expectedMultiline = 'Line 1é\nLine 2à\rLine 3ç'; - self::assertEquals($expectedMultiline, StringManipulation::utf8Ansi($multilineString)); - - // String with tabs - $tabbedString = 'Column1\u00e9\tColumn2\u00e0\tColumn3\u00e7'; - $expectedTabbed = 'Column1é\tColumn2à\tColumn3ç'; - self::assertEquals($expectedTabbed, StringManipulation::utf8Ansi($tabbedString)); - - // String with quotes - $quotedString = '"Caf\u00e9" said the visitor'; - $expectedQuoted = '"Café" said the visitor'; - self::assertEquals($expectedQuoted, StringManipulation::utf8Ansi($quotedString)); - - // String with parentheses and brackets - $bracketsString = 'M\u00fcnchen (Germany) [Baviera]'; - $expectedBrackets = 'München (Germany) [Baviera]'; - self::assertEquals($expectedBrackets, StringManipulation::utf8Ansi($bracketsString)); - } - - - /** - * Test negative flow scenarios for utf8Ansi function. - */ - public function testUtf8AnsiNegativeFlow(): void - { - // Malformed UTF-8 escape sequences - $malformedSequences = [ - '\uGGGG', // Invalid hex - '\u12', // Too short - '\u123G', // Mixed valid/invalid hex - '\uZZZZ', // All invalid hex - '\u', // Incomplete sequence - ]; - - foreach ($malformedSequences as $malformedSequence) { - $result = StringManipulation::utf8Ansi($malformedSequence); - self::assertEquals($malformedSequence, $result, 'Malformed sequence should be returned unchanged: ' . $malformedSequence); - } - - // Mixed valid and invalid sequences - $mixedString = 'Valid: \u00e9 Invalid: \uGGGG More valid: \u00e0'; - $expectedMixed = 'Valid: é Invalid: \uGGGG More valid: à'; - self::assertEquals($expectedMixed, StringManipulation::utf8Ansi($mixedString)); - - // Binary data mixed with UTF-8 sequences - $binaryMixed = "\x00\x01\u00e9\x02\x03"; - $expectedBinary = "\x00\x01é\x02\x03"; - self::assertEquals($expectedBinary, StringManipulation::utf8Ansi($binaryMixed)); - - // Very long string with many sequences - $longString = str_repeat('\u00e9\u00e0\u00e7', 10000); - $startTime = microtime(true); - $result = StringManipulation::utf8Ansi($longString); - $duration = microtime(true) - $startTime; - self::assertLessThan(1.0, $duration, 'Large string conversion should be efficient'); - self::assertStringContainsString('é', $result); - - // Incomplete sequences at string boundaries - $incompleteStart = '\u00'; - self::assertEquals($incompleteStart, StringManipulation::utf8Ansi($incompleteStart)); - - $incompleteEnd = 'text\u00'; - self::assertEquals($incompleteEnd, StringManipulation::utf8Ansi($incompleteEnd)); - - // Case sensitivity in hex digits - uppercase not supported - $upperCaseHex = '\u00C9'; // Not in mapping - $mixedCaseHex = '\u00c9'; // In mapping - self::assertEquals('\u00C9', StringManipulation::utf8Ansi($upperCaseHex)); - self::assertEquals('É', StringManipulation::utf8Ansi($mixedCaseHex)); - - // Unicode sequences outside the mapping range - $outsideRange = '\u1234'; // Not in the predefined mapping - self::assertEquals($outsideRange, StringManipulation::utf8Ansi($outsideRange)); - - // Control characters in sequences - $controlInSequence = "Hello\x00\u00e9\x01World"; - $expectedControl = "Hello\x00é\x01World"; - self::assertEquals($expectedControl, StringManipulation::utf8Ansi($controlInSequence)); - - // Null bytes and sequence handling - $nullByteString = "café\0\u00e9"; - $expectedNull = "café\0é"; - self::assertEquals($expectedNull, StringManipulation::utf8Ansi($nullByteString)); - } - - - /** - * Test edge cases and boundary conditions for utf8Ansi. - */ - public function testUtf8AnsiEdgeCases(): void - { - // All possible valid sequences from the mapping - foreach (self::UTF8_TO_ANSI_MAP as $utf8 => $ansi) { - $result = StringManipulation::utf8Ansi($utf8); - self::assertEquals($ansi, $result, sprintf('UTF-8 sequence %s should convert to %s', $utf8, $ansi)); - } - - // Boundary sequences - $lowerBoundary = '\u00c0'; // First in mapping - $upperBoundary = '\u00ff'; // Last in mapping - self::assertEquals('À', StringManipulation::utf8Ansi($lowerBoundary)); - self::assertEquals('ÿ', StringManipulation::utf8Ansi($upperBoundary)); - - // Just outside boundaries - $belowRange = '\u00bf'; // Just below range - $aboveRange = '\u0100'; // Just above range - self::assertEquals($belowRange, StringManipulation::utf8Ansi($belowRange)); - self::assertEquals($aboveRange, StringManipulation::utf8Ansi($aboveRange)); - - // Massive string with all mappings - $allMappings = implode('', array_keys(self::UTF8_TO_ANSI_MAP)); - $result = StringManipulation::utf8Ansi($allMappings); - foreach (array_values(self::UTF8_TO_ANSI_MAP) as $ansi) { - self::assertStringContainsString($ansi, $result); - } - - // Performance test with repeated patterns - $repeatedPattern = str_repeat('\u00e9\u00e0\u00e7', 5000); - $startTime = microtime(true); - $result = StringManipulation::utf8Ansi($repeatedPattern); - $duration = microtime(true) - $startTime; - self::assertLessThan(0.5, $duration, 'Repeated pattern conversion should be fast'); - self::assertEquals(str_repeat('éàç', 5000), $result); - - // Unicode normalisation edge cases - $denormalised = "e\u0301"; // e + combining acute accent (not in escape form) - $result = StringManipulation::utf8Ansi($denormalised); - self::assertEquals($denormalised, $result); // Should pass through unchanged - - // Maximum input length test - $maxLength = str_repeat('a\u00e9', 100000); - $startTime = microtime(true); - $result = StringManipulation::utf8Ansi($maxLength); - $duration = microtime(true) - $startTime; - self::assertLessThan(2.0, $duration, 'Maximum length conversion should complete in reasonable time'); - self::assertStringContainsString('aé', $result); - } -} +dataset('utf8AnsiMappings', fn(): array => [ + ['\u00c0', 'À'], + ['\u00c1', 'Á'], + ['\u00c2', 'Â'], + ['\u00c3', 'Ã'], + ['\u00c4', 'Ä'], + ['\u00c5', 'Å'], + ['\u00c6', 'Æ'], + ['\u00c7', 'Ç'], + ['\u00c8', 'È'], + ['\u00c9', 'É'], + ['\u00ca', 'Ê'], + ['\u00cb', 'Ë'], + ['\u00cc', 'Ì'], + ['\u00cd', 'Í'], + ['\u00ce', 'Î'], + ['\u00cf', 'Ï'], + ['\u00d1', 'Ñ'], + ['\u00d2', 'Ò'], + ['\u00d3', 'Ó'], + ['\u00d4', 'Ô'], + ['\u00d5', 'Õ'], + ['\u00d6', 'Ö'], + ['\u00d8', 'Ø'], + ['\u00d9', 'Ù'], + ['\u00da', 'Ú'], + ['\u00db', 'Û'], + ['\u00dc', 'Ü'], + ['\u00dd', 'Ý'], + ['\u00df', 'ß'], + ['\u00e0', 'à'], + ['\u00e1', 'á'], + ['\u00e2', 'â'], + ['\u00e3', 'ã'], + ['\u00e4', 'ä'], + ['\u00e5', 'å'], + ['\u00e6', 'æ'], + ['\u00e7', 'ç'], + ['\u00e8', 'è'], + ['\u00e9', 'é'], + ['\u00ea', 'ê'], + ['\u00eb', 'ë'], + ['\u00ec', 'ì'], + ['\u00ed', 'í'], + ['\u00ee', 'î'], + ['\u00ef', 'ï'], + ['\u00f0', 'ð'], + ['\u00f1', 'ñ'], + ['\u00f2', 'ò'], + ['\u00f3', 'ó'], + ['\u00f4', 'ô'], + ['\u00f5', 'õ'], + ['\u00f6', 'ö'], + ['\u00f8', 'ø'], + ['\u00f9', 'ù'], + ['\u00fa', 'ú'], + ['\u00fb', 'û'], + ['\u00fc', 'ü'], + ['\u00fd', 'ý'], + ['\u00ff', 'ÿ'], +]); + +test('utf8 ansi mapping', function (string $utf8, string $ansi): void { + expect(StringManipulation::utf8Ansi($utf8))->toBe($ansi); +})->with('utf8AnsiMappings'); + +test('utf8 ansi empty string', function (): void { + expect(StringManipulation::utf8Ansi(''))->toBe(''); +}); + +test('utf8 ansi null input', function (): void { + expect(StringManipulation::utf8Ansi(null))->toBe(''); +}); + +test('utf8 ansi with invalid character', function (): void { + $string = '\uZZZZ'; + expect(StringManipulation::utf8Ansi($string))->toBe($string); +});