diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 67751958..fd518b65 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -3,7 +3,7 @@ name: Create Release on: push: tags: - - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + - 'v*' # Push events matching v*, i.e. v1.0, v20.15.10 jobs: release: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..9dc814a3 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,62 @@ +name: Tests + +on: + push: + branches: + - "*" + pull_request: + branches: + - "*" + +permissions: + contents: read + +jobs: + test: + name: Run PHPUnit Tests + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: ['8.0', '8.1', '8.2', '8.3'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: json, zip, dom + coverage: xdebug + + - name: Validate composer.json + run: composer validate --strict --no-check-version + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ matrix.php-version }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php-version }}- + + - name: Install dependencies + run: composer update --prefer-dist --no-progress # use update to allow for different php versions in matrix. + + + - name: Run test suite + run: composer test + + - name: Generate coverage report (PHP 8.3 only) + if: matrix.php-version == '8.3' + run: ./vendor/bin/phpunit --coverage-clover coverage.xml + + - name: Upload coverage to artifact (PHP 8.3 only) + if: matrix.php-version == '8.3' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage.xml diff --git a/.gitignore b/.gitignore index d97c6172..072ebc4b 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,8 @@ node_modules /build/ /touchpoint-wp.zip + +# Test artifacts +/.phpunit.cache/ +/coverage/ +.phpunit.result.cache diff --git a/.idea/TouchPoint-WP.iml b/.idea/TouchPoint-WP.iml index 05581e0c..54414f93 100644 --- a/.idea/TouchPoint-WP.iml +++ b/.idea/TouchPoint-WP.iml @@ -6,10 +6,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/libraries/Generated_files.xml b/.idea/libraries/Generated_files.xml index 885dc173..92711876 100644 --- a/.idea/libraries/Generated_files.xml +++ b/.idea/libraries/Generated_files.xml @@ -8,9 +8,16 @@ + + + + + + + @@ -20,9 +27,16 @@ + + + + + + + diff --git a/.idea/php-test-framework.xml b/.idea/php-test-framework.xml new file mode 100644 index 00000000..0dc388b8 --- /dev/null +++ b/.idea/php-test-framework.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/php.xml b/.idea/php.xml index 3935a063..3b861c8c 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -1,5 +1,15 @@ + + + + + + + + + + @@ -9,25 +19,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -42,9 +90,10 @@ + - + @@ -55,7 +104,7 @@ - + diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..9c267e8c --- /dev/null +++ b/TESTING.md @@ -0,0 +1,248 @@ +# Testing TouchPoint-WP + +This document describes how to run and write tests for the TouchPoint-WP plugin. + +## Test Infrastructure + +This project uses: +- **PHPUnit 9.6** for testing framework +- **Yoast PHPUnit Polyfills** for PHP 8.0+ compatibility +- **WordPress filter/action system** implemented directly for integration testing + +The testing approach implements WordPress's filter and action system directly in the bootstrap file, allowing tests to verify real WordPress filter behavior without requiring a full WordPress installation. + +## Running Tests + +### Prerequisites + +- PHP 8.0 or higher +- Composer + +### Installation + +First, install the development dependencies: + +```bash +composer install +``` + +### Running the Test Suite + +To run all tests (unit and integration): + +```bash +composer test +``` + +Or directly with PHPUnit: + +```bash +./vendor/bin/phpunit +``` + +### Running Specific Tests + +To run only unit tests: + +```bash +./vendor/bin/phpunit tests/Unit +``` + +To run only integration tests: + +```bash +./vendor/bin/phpunit tests/Integration +``` + +To run tests in a specific file: + +```bash +./vendor/bin/phpunit tests/Unit/GeoTest.php +``` + +To run a specific test method: + +```bash +./vendor/bin/phpunit --filter test_distance_calculation +``` + +### Code Coverage + +To generate a code coverage report: + +```bash +composer test-coverage +``` + +This will create an HTML coverage report in the `coverage/` directory. Open `coverage/index.html` in your browser to view the report. + +## Writing Tests + +### Test Structure + +Tests are organized in the `tests/` directory: + +- `tests/Unit/` - Unit tests for individual classes and methods +- `tests/Integration/` - Integration tests that test WordPress filter/action integration +- `tests/TestCase.php` - Base test case class +- `tests/bootstrap.php` - Bootstrap file with WordPress filter/action implementations + +### Test Types + +#### Unit Tests + +Unit tests focus on testing individual methods and classes in isolation. + +Example unit test: + +```php +myMethod(); + + $this->assertSame('expected', $result); + } +} +``` + +#### Integration Tests + +Integration tests verify WordPress filter and action integration. The test environment provides real implementations of `add_filter`, `apply_filters`, and `remove_all_filters`. + +Example integration test: + +```php +assertSame('#FF0000', $color); + + // Clean up + remove_all_filters('tp_custom_color_function'); + } +} +``` + +### Testing WordPress Filters + +The bootstrap file implements WordPress's filter system: + +- `add_filter($hook, $callback, $priority, $accepted_args)` - Add a filter +- `apply_filters($hook, $value, ...$args)` - Apply filters to a value +- `remove_all_filters($hook, $priority)` - Remove all filters from a hook + +These functions work like WordPress's actual filter system, including: +- Priority-based ordering +- Multiple filters on the same hook +- Passing multiple arguments to filters +- Filter chaining (each filter receives the output of the previous) +``` + +### Creating a New Test + +1. Create a new test file in the appropriate directory: + - `tests/Unit/` for unit tests + - `tests/Integration/` for integration tests +2. Extend the `tp\TouchPointWP\Tests\TestCase` class +3. Add the `@covers` annotation to specify which class(es) you're testing +4. Write test methods (must start with `test_` or use the `@test` annotation) +5. Use Brain Monkey to mock WordPress functions as needed + +### Test Naming Conventions + +- Test files should be named `{ClassName}_Test.php` +- Test methods should be named `test_{methodName}_{scenario}` (e.g., `test_distance_calculationSamePoint`) +- Use descriptive names that explain what is being tested + +### Assertions + +PHPUnit provides many assertion methods. Common ones include: + +- `assertSame($expected, $actual)` - Strict equality check +- `assertEquals($expected, $actual)` - Loose equality check +- `assertTrue($condition)` - Check if condition is true +- `assertFalse($condition)` - Check if condition is false +- `assertNull($value)` - Check if value is null +- `assertInstanceOf($class, $object)` - Check object type +- `assertEqualsWithDelta($expected, $actual, $delta)` - Check numeric equality with tolerance + +See the [PHPUnit documentation](https://phpunit.readthedocs.io/) for more assertion methods. + +## Continuous Integration + +Tests are automatically run on every push and pull request via GitHub Actions. The test workflow: + +- Runs on PHP versions 8.0, 8.1, 8.2, and 8.3 +- Installs dependencies +- Runs the complete test suite +- Generates code coverage report (PHP 8.3 only) + +You can view the test results in the "Actions" tab of the GitHub repository. + +## Best Practices + +1. **Write isolated tests** - Each test should be independent and not rely on other tests +2. **Test one thing per test** - Each test method should verify one specific behavior +3. **Use descriptive test names** - Test names should clearly explain what is being tested +4. **Mock external dependencies** - Use mocks or stubs for external services and WordPress functions +5. **Keep tests fast** - Unit tests should run quickly; avoid unnecessary setup or external calls +6. **Test edge cases** - Include tests for boundary conditions, error cases, and unusual inputs +7. **Maintain tests** - Update tests when you change code; failing tests should be fixed, not removed + +## Troubleshooting + +### Tests fail with "undefined constant" errors + +Make sure all required constants are defined in `tests/bootstrap.php`. + +### Tests fail with "undefined function" errors + +WordPress functions may need to be mocked in `tests/bootstrap.php` or in individual test files. + +### Can't run tests + +Ensure you have installed dev dependencies: + +```bash +composer install +``` + +If you've only installed production dependencies, add the dev dependencies: + +```bash +composer install --dev +``` diff --git a/composer.json b/composer.json index b0b86438..cc9785e5 100644 --- a/composer.json +++ b/composer.json @@ -27,6 +27,11 @@ "tp\\TouchPointWP\\Interfaces\\": "src/TouchPoint-WP/Interfaces" } }, + "autoload-dev": { + "psr-4": { + "tp\\TouchPointWP\\Tests\\": "tests/" + } + }, "require": { "php": ">=8.0", "composer/installers": "~1.0", @@ -36,11 +41,20 @@ }, "require-dev": { "pronamic/wp-documentor": "^1.3", - "skayo/phpdoc-md": "^0.2.0" + "skayo/phpdoc-md": "^0.2.0", + "phpunit/phpunit": "^9.5", + "yoast/phpunit-polyfills": "^1.0" }, "config": { "allow-plugins": { "composer/installers": true + }, + "audit": { + "block-insecure": false } + }, + "scripts": { + "test": "phpunit", + "test-coverage": "phpunit --coverage-html coverage" } } diff --git a/docs b/docs index 68151bdb..29be1973 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit 68151bdb813cb88298470a1d3e2effd8af98120b +Subproject commit 29be1973968a5d37ad6b36b194680ee98e96a27f diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..4f2b098a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,20 @@ + + + + + tests + + + + + src + + + + + + diff --git a/src/TouchPoint-WP/Geo.php b/src/TouchPoint-WP/Geo.php index b9c7b32f..d28d54dc 100644 --- a/src/TouchPoint-WP/Geo.php +++ b/src/TouchPoint-WP/Geo.php @@ -7,10 +7,6 @@ use stdClass; -if ( ! defined('ABSPATH')) { - exit(1); -} - /** * A standardized set of fields for geographical information. diff --git a/src/TouchPoint-WP/Involvement.php b/src/TouchPoint-WP/Involvement.php index cecb6e0a..ebd17109 100644 --- a/src/TouchPoint-WP/Involvement.php +++ b/src/TouchPoint-WP/Involvement.php @@ -251,7 +251,7 @@ protected function __construct(object $object) } // Color! - $this->color = Utilities::getColorFor("default", "involvement"); + $this->color = Utilities\Colors::getColorFor("default", "involvement"); } $this->registerConstruction(); diff --git a/src/TouchPoint-WP/Partner.php b/src/TouchPoint-WP/Partner.php index c59217a1..f4b21310 100644 --- a/src/TouchPoint-WP/Partner.php +++ b/src/TouchPoint-WP/Partner.php @@ -200,7 +200,7 @@ protected function __construct(object $object) // Color! if (count($this->category) > 0) { $c = $this->category[0]; - $this->color = Utilities::getColorFor($c->slug, $c->taxonomy); + $this->color = Utilities\Colors::getColorFor($c->slug, $c->taxonomy); } $this->registerConstruction(); diff --git a/src/TouchPoint-WP/Person.php b/src/TouchPoint-WP/Person.php index 9813cac4..06d21aad 100644 --- a/src/TouchPoint-WP/Person.php +++ b/src/TouchPoint-WP/Person.php @@ -694,7 +694,7 @@ protected static function updateFromTouchPoint(bool $verbose = false): bool|int // Find People Lists in post content and add their involvements to the query. if (TouchPointWP::instance()->settings->enable_people_lists) { - $posts = Utilities::getPostContentWithShortcode(self::SHORTCODE_PEOPLE_LIST); + $posts = Utilities\Database::getPostContentWithShortcode(self::SHORTCODE_PEOPLE_LIST); global $post; $originalPost = $post; diff --git a/src/TouchPoint-WP/Report.php b/src/TouchPoint-WP/Report.php index c40186d5..9d616c8a 100644 --- a/src/TouchPoint-WP/Report.php +++ b/src/TouchPoint-WP/Report.php @@ -20,18 +20,6 @@ use WP_Post; use WP_Query; -if ( ! defined('ABSPATH')) { - exit(1); -} - -if ( ! TOUCHPOINT_COMPOSER_ENABLED) { - require_once "Interfaces/api.php"; - require_once "Interfaces/updatesViaCron.php"; - require_once "Interfaces/storedAsPost.php"; - require_once "Utilities/ImageConversions.php"; - require_once "Utilities/Http.php"; - require_once "Utilities/Database.php"; -} /** * The Report class gets and processes a SQL or Python report from TouchPoint and presents it in the UX. @@ -579,7 +567,7 @@ public static function updateFromTouchPoint(bool $forceEvenIfNotDue = false): in TouchPointWP::instance()->setTpWpUserAsCurrent(); // Find Report Shortcodes in post content and add their involvements to the query. - $referencingPosts = Utilities::getPostContentWithShortcode(self::SHORTCODE_REPORT); + $referencingPosts = Database::getPostContentWithShortcode(self::SHORTCODE_REPORT); $postIdsToNotDelete = []; diff --git a/src/TouchPoint-WP/Utilities.php b/src/TouchPoint-WP/Utilities.php index a44d5f1d..3dadcbc3 100644 --- a/src/TouchPoint-WP/Utilities.php +++ b/src/TouchPoint-WP/Utilities.php @@ -340,122 +340,6 @@ public static function idArrayToIntArray(array|string $r, bool $explode = true): return $r; } - /** - * Gets the post content for all posts that contain a particular shortcode. - * - * @param $shortcode - * - * TODO MULTI: does not update for all sites in the network. - * - * @return object[] - */ - public static function getPostContentWithShortcode($shortcode): array - { - global $wpdb; - - /** @noinspection SqlResolve */ - return $wpdb->get_results("SELECT post_content FROM $wpdb->posts WHERE post_content LIKE '%$shortcode%' AND post_status <> 'inherit'"); - } - - protected static array $colorAssignments = []; - - /** - * Arbitrarily pick a unique-ish color for a value. - * - * @param string $itemName The name of the item. e.g. PA - * @param string $setName The name of the set to which the item belongs, within which there should be uniqueness. - * e.g. States - * - * @return string The color in hex, starting with '#'. - */ - public static function getColorFor(string $itemName, string $setName): string - { - $current = null; - - /** - * Allows for a custom color function to assign a color for a given value. - * - * @since 0.0.90 Added - * - * @param ?string $current The current value. Null is provided to the function because the color hasn't otherwise been determined yet. - * @param string $itemName The name of the current item. - * @param string $setName The name of the set to which the item belongs. - * - * @return ?string The color in hex, starting with '#'. Null to defer to the default color assignment. - */ - $r = apply_filters('tp_custom_color_function', $current, $itemName, $setName); - if ($r !== null) - return $r; - - // If the set is new... - if ( ! isset(self::$colorAssignments[$setName])) { - self::$colorAssignments[$setName] = []; - } - - // Find position in set... - $idx = array_search($itemName, self::$colorAssignments[$setName], true); - - // If not in set... - if ($idx === false) { - $idx = count(self::$colorAssignments[$setName]); - self::$colorAssignments[$setName][] = $itemName; - } - - $array = []; - /** - * Allows for a custom color set to be used for color assignment to match branding. This filter should return an - * array of colors in hex format, starting with '#'. The colors will be assigned in order, but it is not - * deterministic which color will be assigned to which item. If it needs to be, use the `tp_custom_color_function` - * filter instead. - * - * @since 0.0.90 Added - * - * @param string[] $array The array of colors in hex format strings, starting with '#'. - * @param string $setName The name of the set for which the colors are needed. - */ - $colorSet = apply_filters('tp_custom_color_set', $array, $setName); - - if (count($colorSet) > 0) { - return $colorSet[$idx % count($colorSet)]; - } - - // Calc color! (This method generates 24 colors and then repeats. (8 hues * 3 lums) - $h = ($idx * 135) % 360; - $l = ((($idx >> 3) + 1) * 25) % 75 + 25; - - return self::hslToHex($h, 70, $l); - } - - /** - * Convert HSL color to RGB Color - * - * @param int $h Hue (0-365) - * @param int $s Saturation (0-100) - * @param int $l Luminosity (0-100) - * - * @return string - * - * @cite Adapted from https://stackoverflow.com/a/44134328/2339939 - * @license CC BY-SA 4.0 - */ - public static function hslToHex(int $h, int $s, int $l): string - { - $l /= 100; - $a = $s * min($l, 1 - $l) / 100; - - $f = function ($n) use ($h, $l, $a) { - $k = ($n + $h / 30) % 12; - $color = $l - $a * max(min($k - 3, 9 - $k, 1), -1); - - return round(255 * $color); - }; - - return "#" . - str_pad(dechex($f(0)), 2, 0, STR_PAD_LEFT) . - str_pad(dechex($f(8)), 2, 0, STR_PAD_LEFT) . - str_pad(dechex($f(4)), 2, 0, STR_PAD_LEFT); - } - /** * Get the registered post types as a Key-Value array. Excludes post types that start with 'tp_'. * diff --git a/src/TouchPoint-WP/Utilities/Colors.php b/src/TouchPoint-WP/Utilities/Colors.php new file mode 100644 index 00000000..50b3638a --- /dev/null +++ b/src/TouchPoint-WP/Utilities/Colors.php @@ -0,0 +1,114 @@ + 0) { + return $colorSet[$idx % count($colorSet)]; + } + + // Calc color! (This method generates 24 colors and then repeats. (8 hues * 3 lums) + $h = ($idx * 135) % 360; + $l = ((($idx >> 3) + 1) * 25) % 75 + 25; + + return self::hslToHex($h, 70, $l); + } + + /** + * Convert HSL color to RGB Color + * + * @param int $h Hue (0-365) + * @param int $s Saturation (0-100) + * @param int $l Luminosity (0-100) + * + * @return string + * + * @cite Adapted from https://stackoverflow.com/a/44134328/2339939 + * @license CC BY-SA 4.0 + */ + public static function hslToHex(int $h, int $s, int $l): string + { + $l /= 100; + $a = $s * min($l, 1 - $l) / 100; + + $f = function ($n) use ($h, $l, $a) { + $k = intval($n + $h / 30) % 12; + $color = $l - $a * max(min($k - 3, 9 - $k, 1), -1); + + return round(255 * $color); + }; + + return "#" . + str_pad(dechex($f(0)), 2, 0, STR_PAD_LEFT) . + str_pad(dechex($f(8)), 2, 0, STR_PAD_LEFT) . + str_pad(dechex($f(4)), 2, 0, STR_PAD_LEFT); + } +} \ No newline at end of file diff --git a/src/TouchPoint-WP/Utilities/Database.php b/src/TouchPoint-WP/Utilities/Database.php index e1851d4c..7c974179 100644 --- a/src/TouchPoint-WP/Utilities/Database.php +++ b/src/TouchPoint-WP/Utilities/Database.php @@ -42,4 +42,23 @@ public static function deletePostMetaByPrefix(int $postId, string $prefix): bool } return $success; } + + /** + * Gets the post content for all posts that contain a particular shortcode. + * + * @param $shortcode + * + * TODO MULTI: does not update for all sites in the network. + * + * @return object[] + */ + public static function getPostContentWithShortcode($shortcode): array + { + global $wpdb; + + /** @noinspection SqlResolve */ + return $wpdb->get_results( + "SELECT post_content FROM $wpdb->posts WHERE post_content LIKE '%$shortcode%' AND post_status <> 'inherit'" + ); + } } \ No newline at end of file diff --git a/src/templates/admin/invKoForm.php b/src/templates/admin/invKoForm.php index b4ebd498..78b70d8e 100644 --- a/src/templates/admin/invKoForm.php +++ b/src/templates/admin/invKoForm.php @@ -317,7 +317,7 @@ function InvType(data) { this.excludeIf = ko.observable(data.excludeIf ?? []); this.hierarchical = ko.observable(data.hierarchical ?? false); this.importMeetings = ko.observable(data.importMeetings ?? false); - this.meetingGroupingMethod = ko.observable(data.meetingGroupingMethod ?? ); + this.meetingGroupingMethod = ko.observable(data.meetingGroupingMethod ?? ""); this.groupBy = ko.observable(data.groupBy ?? ""); this.leaderTypes = ko.observableArray(data.leaderTypes ?? []); this.hostTypes = ko.observableArray(data.hostTypes ?? []); diff --git a/tests/Integration/ColorsFiltersTest.php b/tests/Integration/ColorsFiltersTest.php new file mode 100644 index 00000000..692eb6d2 --- /dev/null +++ b/tests/Integration/ColorsFiltersTest.php @@ -0,0 +1,249 @@ +assertSame('#FF0000', $color); + + // Clean up filter + remove_all_filters('tp_custom_color_function'); + } + + /** + * Test that tp_custom_color_function filter receives correct parameters. + */ + public function test_custom_color_function_filter_receives_correct_params(): void + { + $receivedParams = []; + + add_filter('tp_custom_color_function', function($current, $itemName, $setName) use (&$receivedParams) { + $receivedParams = [ + 'current' => $current, + 'itemName' => $itemName, + 'setName' => $setName + ]; + return null; // Don't override + }, 10, 3); + + Colors::getColorFor('NY', 'States'); + + $this->assertNull($receivedParams['current']); + $this->assertSame('NY', $receivedParams['itemName']); + $this->assertSame('States', $receivedParams['setName']); + + remove_all_filters('tp_custom_color_function'); + } + + /** + * Test that tp_custom_color_function filter can return null to defer to default. + */ + public function test_custom_color_function_filter_can_defer_to_default(): void + { + add_filter('tp_custom_color_function', function($current, $itemName, $setName) { + return null; // Defer to default color assignment + }, 10, 3); + + $color = Colors::getColorFor('CA', 'States'); + + // Should return a valid hex color (default behavior) + $this->assertStringStartsWith('#', $color); + $this->assertSame(7, strlen($color)); // # + 6 hex chars + + remove_all_filters('tp_custom_color_function'); + } + + /** + * Test that tp_custom_color_set filter can provide custom color palette. + */ + public function test_custom_color_set_filter_provides_palette(): void + { + add_filter('tp_custom_color_set', function($array, $setName) { + $customColors = ['#FF0000', '#00FF00', '#0000FF']; + if ($setName === 'TestSet') { + return $customColors; + } + return $array; + }, 10, 2); + + $color1 = Colors::getColorFor('Item1', 'TestSet'); + $color2 = Colors::getColorFor('Item2', 'TestSet'); + $color3 = Colors::getColorFor('Item3', 'TestSet'); + $color4 = Colors::getColorFor('Item4', 'TestSet'); + + $this->assertSame('#FF0000', $color1); + $this->assertSame('#00FF00', $color2); + $this->assertSame('#0000FF', $color3); + $this->assertSame('#FF0000', $color4); // Wraps around to first color + + remove_all_filters('tp_custom_color_set'); + } + + /** + * Test that tp_custom_color_set filter receives correct parameters. + */ + public function test_custom_color_set_filter_receives_correct_params(): void + { + $receivedParams = []; + + add_filter('tp_custom_color_set', function($array, $setName) use (&$receivedParams) { + $receivedParams = [ + 'array' => $array, + 'setName' => $setName + ]; + return []; + }, 10, 2); + + Colors::getColorFor('Item1', 'MySet'); + + $this->assertIsArray($receivedParams['array']); + $this->assertEmpty($receivedParams['array']); // Should be empty array initially + $this->assertSame('MySet', $receivedParams['setName']); + + remove_all_filters('tp_custom_color_set'); + } + + /** + * Test that empty custom color set falls back to default algorithm. + */ + public function test_empty_custom_color_set_uses_default_algorithm(): void + { + add_filter('tp_custom_color_set', function($array, $setName) { + return []; // Return empty array + }, 10, 2); + + $color = Colors::getColorFor('Item1', 'TestSet'); + + // Should still return a valid hex color using default algorithm + $this->assertStringStartsWith('#', $color); + $this->assertSame(7, strlen($color)); + + remove_all_filters('tp_custom_color_set'); + } + + /** + * Test that custom color function takes precedence over custom color set. + */ + public function test_custom_color_function_takes_precedence_over_color_set(): void + { + // Set up both filters + add_filter('tp_custom_color_set', function($array, $setName) { + return ['#00FF00', '#0000FF']; // Green and Blue + }, 10, 2); + + add_filter('tp_custom_color_function', function($current, $itemName, $setName) { + if ($itemName === 'Special') { + return '#FF0000'; // Red + } + return null; + }, 10, 3); + + $normalColor = Colors::getColorFor('Normal', 'TestSet'); + $specialColor = Colors::getColorFor('Special', 'TestSet'); + + // Normal item should use color set + $this->assertSame('#00FF00', $normalColor); + + // Special item should use custom function (takes precedence) + $this->assertSame('#FF0000', $specialColor); + + remove_all_filters('tp_custom_color_set'); + remove_all_filters('tp_custom_color_function'); + } + + /** + * Test that colors are consistent for same item/set combination. + */ + public function test_colors_are_consistent_for_same_item(): void + { + $color1 = Colors::getColorFor('TX', 'States'); + $color2 = Colors::getColorFor('TX', 'States'); + $color3 = Colors::getColorFor('TX', 'States'); + + $this->assertSame($color1, $color2); + $this->assertSame($color2, $color3); + } + + /** + * Test that different items in same set get different colors (when possible). + */ + public function test_different_items_in_same_set_get_different_colors(): void + { + $colorA = Colors::getColorFor('ItemA', 'TestSet'); + $colorB = Colors::getColorFor('ItemB', 'TestSet'); + $colorC = Colors::getColorFor('ItemC', 'TestSet'); + + // First few items should have different colors + $this->assertNotSame($colorA, $colorB); + $this->assertNotSame($colorB, $colorC); + $this->assertNotSame($colorA, $colorC); + } + + /** + * Test that same item in different sets can have different colors. + */ + public function test_same_item_different_sets_can_have_different_colors(): void + { + $colorInSet1 = Colors::getColorFor('Item', 'Set1'); + $colorInSet2 = Colors::getColorFor('Item', 'Set2'); + + // Same item name in different sets may have different colors + // (depends on their position in each set) + $this->assertIsString($colorInSet1); + $this->assertIsString($colorInSet2); + $this->assertStringStartsWith('#', $colorInSet1); + $this->assertStringStartsWith('#', $colorInSet2); + } + + /** + * Test filter priority - higher priority filter should override lower priority. + */ + public function test_filter_priority_works_correctly(): void + { + // Add filter with priority 10 + add_filter('tp_custom_color_function', function($current, $itemName, $setName) { + return '#00FF00'; // Green + }, 10, 3); + + // Add filter with higher priority (20) - should run last and win + add_filter('tp_custom_color_function', function($current, $itemName, $setName) { + return '#FF0000'; // Red + }, 20, 3); + + $color = Colors::getColorFor('Item', 'TestSet'); + + // Higher priority (20) filter should win + $this->assertSame('#FF0000', $color); + + remove_all_filters('tp_custom_color_function'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..9cbe7dc9 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,41 @@ +assertSame($message, $exception->getMessage()); + $this->assertSame(0, $exception->getCode()); + } + + /** + * Test that exception can be instantiated with message and code. + */ + public function test_exception_instantiation_with_code(): void + { + $message = 'Test error message'; + $code = 404; + $exception = new TouchPointWP_Exception($message, $code); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($code, $exception->getCode()); + } + + /** + * Test that exception can be thrown and caught. + */ + public function test_exception_can_be_thrown_and_caught(): void + { + $message = 'Test error message'; + + try { + throw new TouchPointWP_Exception($message); + } catch (TouchPointWP_Exception $e) { + $this->assertSame($message, $e->getMessage()); + return; + } + /** @noinspection PhpUnreachableStatementInspection */ + $this->fail('Exception should have been thrown'); + } + + /** + * Test that exception can be created with previous exception. + */ + public function test_exception_with_previous_exception(): void + { + $previousMessage = 'Previous error'; + $message = 'Current error'; + + $previous = new \Exception($previousMessage); + $exception = new TouchPointWP_Exception($message, 0, $previous); + + $this->assertSame($message, $exception->getMessage()); + $this->assertNotNull($exception->getPrevious()); + $this->assertSame($previousMessage, $exception->getPrevious()->getMessage()); + } + + /** + * Test toJson method returns valid JSON. + */ + public function test_to_json_returns_valid_json(): void + { + $message = 'Test error message'; + $code = 500; + $exception = new TouchPointWP_Exception($message, $code); + + $json = $exception->toJson(); + $this->assertIsString($json); + + $decoded = json_decode($json, true); + $this->assertIsArray($decoded); + $this->assertArrayHasKey('error', $decoded); + $this->assertArrayHasKey('status', $decoded['error']); + $this->assertArrayHasKey('code', $decoded['error']); + $this->assertArrayHasKey('message', $decoded['error']); + $this->assertArrayHasKey('location', $decoded['error']); + + $this->assertSame('failure', $decoded['error']['status']); + $this->assertSame($code, $decoded['error']['code']); + $this->assertSame($message, $decoded['error']['message']); + } + + /** + * Test toWpError method returns WP_Error object. + */ + public function test_to_wp_error_returns_wp_error(): void + { + $message = 'Test error message'; + $code = 404; + $exception = new TouchPointWP_Exception($message, $code); + + $wpError = $exception->toWpError(); + + // WP_Error is mocked in our test environment, but we can check the structure + $this->assertInstanceOf(\WP_Error::class, $wpError); + } + + /** + * Test exception extends standard PHP Exception. + */ + public function test_exception_extends_exception(): void + { + $exception = new TouchPointWP_Exception('test'); + $this->assertInstanceOf(\Exception::class, $exception); + } + + /** + * Test exception has file and line information. + */ + public function test_exception_has_file_and_line_info(): void + { + $exception = new TouchPointWP_Exception('test'); + + $this->assertIsString($exception->getFile()); + $this->assertIsInt($exception->getLine()); + $this->assertGreaterThan(0, $exception->getLine()); + } + + /** + * Test exception has trace information. + */ + public function test_exception_has_trace_info(): void + { + $exception = new TouchPointWP_Exception('test'); + + $trace = $exception->getTrace(); + $this->assertIsArray($trace); + + $traceString = $exception->getTraceAsString(); + $this->assertIsString($traceString); + } +} diff --git a/tests/Unit/Utilities/Colors_Test.php b/tests/Unit/Utilities/Colors_Test.php new file mode 100644 index 00000000..596904f1 --- /dev/null +++ b/tests/Unit/Utilities/Colors_Test.php @@ -0,0 +1,59 @@ +assertEquals('#ff0000', Colors::hslToHex(0, 100, 50)); // Red + $this->assertEquals('#00ff00', Colors::hslToHex(120, 100, 50)); // Green + $this->assertEquals('#0000ff', Colors::hslToHex(240, 100, 50)); // Blue + } + + public function test_hslToHex_common() + { + // Test edge values + $this->assertEquals('#808080', Colors::hslToHex(0, 0, 50)); // Gray + $this->assertEquals('#ffffff', Colors::hslToHex(0, 0, 100)); // White + $this->assertEquals('#000000', Colors::hslToHex(0, 0, 0)); // Black + } + + public function test_getColorFor_returnsHex() + { + $color = Colors::getColorFor('TestItem', 'TestSet'); + $this->assertMatchesRegularExpression('/^#[0-9a-fA-F]{6}$/', $color); + } + + public function test_getColorFor_UniquenessWithinSet() + { + $color1 = Colors::getColorFor('Item1', 'SetA'); + $color2 = Colors::getColorFor('Item2', 'SetA'); + $this->assertNotEquals($color1, $color2); + } + + public function test_getColorFor_sameColorForSameItem() + { + $color1 = Colors::getColorFor('ItemX', 'SetB'); + Colors::getColorFor('ItemY', 'SetB'); + $color2 = Colors::getColorFor('ItemX', 'SetB'); + $this->assertEquals($color1, $color2); + } + + public function test_getColorFor_differentSets() + { + $colorA = Colors::getColorFor('ItemY', 'Set1'); + $colorB = Colors::getColorFor('ItemY', 'Set2'); + $this->assertEquals($colorA, $colorB); + } +} + + diff --git a/tests/Unit/Utilities/Geo_Test.php b/tests/Unit/Utilities/Geo_Test.php new file mode 100644 index 00000000..60c02eff --- /dev/null +++ b/tests/Unit/Utilities/Geo_Test.php @@ -0,0 +1,137 @@ +assertNull($geo->lat); + $this->assertNull($geo->lng); + $this->assertNull($geo->human); + $this->assertNull($geo->type); + } + + /** + * Test that Geo object can be instantiated with specific values. + */ + public function test_instantiation_withValues(): void + { + $lat = 40.7128; + $lng = -74.0060; + $human = 'New York, NY'; + $type = 'city'; + + $geo = new Geo($lat, $lng, $human, $type); + + $this->assertSame($lat, $geo->lat); + $this->assertSame($lng, $geo->lng); + $this->assertSame($human, $geo->human); + $this->assertSame($type, $geo->type); + } + + /** + * Test distance calculation between two points. + * Testing with known coordinates (New York to Los Angeles). + */ + public function test_distance_calculation(): void + { + // New York coordinates + $nyLat = 40.7128; + $nyLng = -74.0060; + + // Los Angeles coordinates + $laLat = 34.0522; + $laLng = -118.2437; + + $distance = Geo::distance($nyLat, $nyLng, $laLat, $laLng); + + // Expected distance is approximately 2451 miles + // Allow for some rounding variance + $this->assertEqualsWithDelta(2451, $distance, 10); + } + + /** + * Test distance calculation returns zero for same coordinates. + */ + public function test_distance_samePoint(): void + { + $lat = 40.7128; + $lng = -74.0060; + + $distance = Geo::distance($lat, $lng, $lat, $lng); + + $this->assertSame(0.0, $distance); + } + + /** + * Test distance calculation with nearby points. + */ + public function test_distance_nearby(): void + { + // Two points very close together (approximately 1 mile apart) + $lat1 = 40.7128; + $lng1 = -74.0060; + $lat2 = 40.7260; + $lng2 = -74.0050; + + $distance = Geo::distance($lat1, $lng1, $lat2, $lng2); + + // Should be close to 1 mile + $this->assertGreaterThan(0, $distance); + $this->assertLessThan(2, $distance); + } + + /** + * Test that distance calculation handles negative coordinates (Southern/Western hemispheres). + */ + public function test_distance_negativeCoordinates(): void + { + // Sydney, Australia + $sydneyLat = -33.8688; + $sydneyLng = 151.2093; + + // Melbourne, Australia + $melbourneLat = -37.8136; + $melbourneLng = 144.9631; + + $distance = Geo::distance($sydneyLat, $sydneyLng, $melbourneLat, $melbourneLng); + + // Expected distance is approximately 443 miles + $this->assertEqualsWithDelta(443, $distance, 10); + } + + /** + * Test creating Geo objects with timezone-aware datetimes from Utilities. + */ + public function test_properties(): void + { + // Create a Geo object representing a location + $location = new Geo(51.5074, -0.1278, 'London, UK', 'nav'); + + // Verify the Geo object was created correctly + $this->assertSame(51.5074, $location->lat); + $this->assertSame(-0.1278, $location->lng); + $this->assertSame('London, UK', $location->human); + $this->assertSame('nav', $location->type); + } +} diff --git a/tests/Unit/Utilities/StringableArray_Test.php b/tests/Unit/Utilities/StringableArray_Test.php new file mode 100644 index 00000000..3c78394d --- /dev/null +++ b/tests/Unit/Utilities/StringableArray_Test.php @@ -0,0 +1,192 @@ +assertInstanceOf(StringableArray::class, $array); + $this->assertSame(0, $array->count()); + } + + /** + * Test that StringableArray can be instantiated with initial data. + */ + public function test_instantiation_with_initial_data(): void + { + $data = ['apple', 'banana', 'cherry']; + $array = new StringableArray($data); + + $this->assertSame(3, $array->count()); + } + + /** + * Test count method returns correct count. + */ + public function test_count_returns_correct_count(): void + { + $data = ['one', 'two', 'three']; + $array = new StringableArray($data); + + $this->assertSame(3, $array->count()); + $this->assertCount(3, $array); + } + + /** + * Test append adds items to the end. + */ + public function test_append_adds_items_to_end(): void + { + $array = new StringableArray(['first']); + $array->append('second'); + $array->append('third'); + + $this->assertSame(3, $array->count()); + $this->assertSame('first, second, third', $array->join(', ')); + } + + /** + * Test prepend adds items to the beginning. + */ + public function test_prepend_adds_items_to_beginning(): void + { + $array = new StringableArray(['second']); + $array->prepend('first'); + + $this->assertSame(2, $array->count()); + $this->assertSame('first, second', $array->join(', ')); + } + + /** + * Test prepend with key. + */ + public function test_prepend_with_key(): void + { + $array = new StringableArray(['b' => 'second']); + $array->prepend('first', 'a'); + + $this->assertSame(2, $array->count()); + $this->assertSame('first, second', $array->join(', ')); + } + + /** + * Test contains returns true for existing items. + */ + public function test_contains_returns_true_for_existing_items(): void + { + $array = new StringableArray(['apple', 'banana', 'cherry']); + + $this->assertTrue($array->contains('apple')); + $this->assertTrue($array->contains('banana')); + $this->assertTrue($array->contains('cherry')); + } + + /** + * Test contains returns false for non-existing items. + */ + public function test_contains_returns_false_for_non_existing_items(): void + { + $array = new StringableArray(['apple', 'banana', 'cherry']); + + $this->assertFalse($array->contains('orange')); + $this->assertFalse($array->contains('grape')); + } + + /** + * Test join with default separator. + */ + public function test_join_with_default_separator(): void + { + $array = new StringableArray(['apple', 'banana', 'cherry']); + + $this->assertSame("apple\nbanana\ncherry", $array->join()); + } + + /** + * Test join with custom separator. + */ + public function test_join_with_custom_separator(): void + { + $array = new StringableArray(['apple', 'banana', 'cherry']); + + $this->assertSame('apple | banana | cherry', $array->join(' | ')); + } + + /** + * Test single item array. + */ + public function test_single_item_array(): void + { + $array = new StringableArray(['only']); + + $this->assertSame('only', $array->join()); + $this->assertSame(1, $array->count()); + } + + /** + * Test array with numeric values. + */ + public function test_array_with_numeric_values(): void + { + $array = new StringableArray([1, 2, 3, 4, 5]); + + $this->assertSame('1, 2, 3, 4, 5', $array->join(', ')); + } + + /** + * Test array operations don't affect original separator. + */ + public function test_operations_preserve_original_separator(): void + { + $array = new StringableArray(['a', 'b']); + + $this->assertSame('a | b', $array->join(' | ')); + $this->assertSame("a\nb", (string)$array); + } + + /** + * Test StringableArray extends ArrayObject. + */ + public function test_extends_array_object(): void + { + $array = new StringableArray(); + + $this->assertInstanceOf(\ArrayObject::class, $array); + } + + /** + * Test array access operations. + */ + public function test_array_access_operations(): void + { + $array = new StringableArray(['a', 'b', 'c']); + + // Access + $this->assertSame('a', $array[0]); + $this->assertSame('b', $array[1]); + $this->assertSame('c', $array[2]); + + // Modification + $array[1] = 'modified'; + $this->assertSame('a, modified, c', $array->join(', ')); + } +} diff --git a/tests/Unit/Utilities_Test.php b/tests/Unit/Utilities_Test.php new file mode 100644 index 00000000..4c52d2f0 --- /dev/null +++ b/tests/Unit/Utilities_Test.php @@ -0,0 +1,398 @@ +getProperty($propertyName); + $property->setAccessible(true); + $property->setValue(null, null); + } + } + + /** + * Test toFloatOrNull returns null for non-numeric values. + */ + public function test_toFloatOrNull_nonNumeric(): void + { + $this->assertNull(Utilities::toFloatOrNull('not a number')); + $this->assertNull(Utilities::toFloatOrNull('abc')); + $this->assertNull(Utilities::toFloatOrNull([])); + $this->assertNull(Utilities::toFloatOrNull(null)); + } + + /** + * Test toFloatOrNull converts numeric strings to float. + */ + public function test_toFloatOrNull_numericStrings(): void + { + $this->assertSame(123.0, Utilities::toFloatOrNull('123')); + $this->assertSame(123.45, Utilities::toFloatOrNull('123.45')); + $this->assertSame(-45.67, Utilities::toFloatOrNull('-45.67')); + } + + /** + * Test toFloatOrNull converts integers to float. + */ + public function test_toFloatOrNull_integers(): void + { + $this->assertSame(123.0, Utilities::toFloatOrNull(123)); + $this->assertSame(0.0, Utilities::toFloatOrNull(0)); + $this->assertSame(-456.0, Utilities::toFloatOrNull(-456)); + } + + /** + * Test toFloatOrNull with rounding. + */ + public function test_toFloatOrNull_rounding(): void + { + $this->assertSame(123.46, Utilities::toFloatOrNull(123.456, 2)); + $this->assertSame(123.5, Utilities::toFloatOrNull(123.456, 1)); + $this->assertSame(123.0, Utilities::toFloatOrNull(123.456, 0)); + } + + /** + * Test dateTimeNow returns DateTimeImmutable. + */ + public function test_dateTimeNow_returnsDateTimeImmutable(): void + { + $now = Utilities::dateTimeNow(); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DateTimeImmutable::class, $now); + } + + /** + * Test dateTimeNow caches the result. + */ + public function test_dateTimeNow_cachesResult(): void + { + $first = Utilities::dateTimeNow(); + sleep(1); // Ensure time would differ if not cached + $second = Utilities::dateTimeNow(); + + // Should be the exact same instance due to caching + $this->assertSame($first, $second); + } + + /** + * Test dateTimeTodayAtMidnight returns midnight. + */ + public function test_dateTimeTodayAtMidnight(): void + { + $midnight = Utilities::dateTimeTodayAtMidnight(); + + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DateTimeImmutable::class, $midnight); + $this->assertSame('00:00', $midnight->format('H:i')); + $this->assertSame(current_datetime()->format('Y-m-d'), $midnight->format('Y-m-d')); + } + + /** + * Test dateTimeNowPlus1D is approximately one day in the future. + */ + public function test_dateTimeNowPlus1D(): void + { + $now = Utilities::dateTimeNow(); + $tomorrow = Utilities::dateTimeNowPlus1D(); + + $diff = $tomorrow->getTimestamp() - $now->getTimestamp(); + + // Should be approximately 86400 seconds (1 day) + $this->assertEqualsWithDelta(86400, $diff, 1); + } + + /** + * Test dateTimeNowPlus90D is approximately 90 days in the future. + */ + public function test_dateTimeNowPlus90D(): void + { + $now = Utilities::dateTimeNow(); + $future = Utilities::dateTimeNowPlus90D(); + + $diff = $future->getTimestamp() - $now->getTimestamp(); + + // Should be approximately 7776000 seconds (90 days) + $this->assertEqualsWithDelta(7776000, $diff, 1); + } + + /** + * Test dateTimeNowPlus1Y is approximately one year in the future. + */ + public function test_dateTimeNowPlus1Y(): void + { + $now = Utilities::dateTimeNow(); + $nextYear = Utilities::dateTimeNowPlus1Y(); + + $diff = $nextYear->getTimestamp() - $now->getTimestamp(); + + // Should be approximately 31536000 seconds (365 days) + // Allow for leap years + $this->assertEqualsWithDelta(31536000, $diff, 86400); + } + + /** + * Test dateTimeNowMinus1D is approximately one day in the past. + */ + public function test_dateTimeNowMinus1D(): void + { + $now = Utilities::dateTimeNow(); + $yesterday = Utilities::dateTimeNowMinus1D(); + + $diff = $now->getTimestamp() - $yesterday->getTimestamp(); + + // Should be approximately 86400 seconds (1 day) + $this->assertEqualsWithDelta(86400, $diff, 1); + } + + /** + * Test utcTimeZone returns UTC timezone. + */ + public function test_utcTimeZone(): void + { + $utc = Utilities::utcTimeZone(); + + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DateTimeZone::class, $utc); + $this->assertSame('UTC', $utc->getName()); + } + + /** + * Test utcTimeZone caches the result. + */ + public function test_utcTimeZone_caching(): void + { + $first = Utilities::utcTimeZone(); + $second = Utilities::utcTimeZone(); + + // Should be the exact same instance due to caching + $this->assertSame($first, $second); + } + + /** + * Test getPluralDayOfWeekNameForNumber_noI18n returns correct day names. + */ + public function test_getPluralDayOfWeekNameForNumber_noI18n(): void + { + $this->assertSame('Sundays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(0)); + $this->assertSame('Mondays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(1)); + $this->assertSame('Tuesdays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(2)); + $this->assertSame('Wednesdays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(3)); + $this->assertSame('Thursdays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(4)); + $this->assertSame('Fridays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(5)); + $this->assertSame('Saturdays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(6)); + } + + /** + * Test getPluralDayOfWeekNameForNumber_noI18n handles modulo correctly. + */ + public function test_getPluralDayOfWeekNameForNumber_noI18n_gt7(): void + { + // Test that numbers > 6 wrap around + $this->assertSame('Sundays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(7)); + $this->assertSame('Mondays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(8)); + $this->assertSame('Sundays', Utilities::getPluralDayOfWeekNameForNumber_noI18n(14)); + } + + /** + * Test converting numeric strings to floats with Geo coordinates. + */ + public function test_toFloatOrNull_string(): void + { + // Convert string coordinates to floats + $lat = Utilities::toFloatOrNull('40.7128', 4); + $lng = Utilities::toFloatOrNull('-74.0060', 4); + + $this->assertSame(40.7128, $lat); + $this->assertSame(-74.0060, $lng); + } + + /** + * Test getPluralDayOfWeekNameForNumber returns correct day names. + */ + public function test_getPluralDayOfWeekNameForNumber(): void + { + $this->assertSame('Sundays', Utilities::getPluralDayOfWeekNameForNumber(0)); + $this->assertSame('Mondays', Utilities::getPluralDayOfWeekNameForNumber(1)); + $this->assertSame('Tuesdays', Utilities::getPluralDayOfWeekNameForNumber(2)); + $this->assertSame('Wednesdays', Utilities::getPluralDayOfWeekNameForNumber(3)); + $this->assertSame('Thursdays', Utilities::getPluralDayOfWeekNameForNumber(4)); + $this->assertSame('Fridays', Utilities::getPluralDayOfWeekNameForNumber(5)); + $this->assertSame('Saturdays', Utilities::getPluralDayOfWeekNameForNumber(6)); + } + + /** + * Test getPluralDayOfWeekNameForNumber returns correct day names. + */ + public function test_getPluralDayOfWeekNameForNumber_gt7(): void + { + $this->assertSame('Saturdays', Utilities::getPluralDayOfWeekNameForNumber(13)); + } + + /** + * Test getDayOfWeekShortForNumber returns correct day names. + */ + public function test_getDayOfWeekShortForNumber(): void + { + $this->assertSame('Sun', Utilities::getDayOfWeekShortForNumber(0)); + $this->assertSame('Mon', Utilities::getDayOfWeekShortForNumber(1)); + $this->assertSame('Tue', Utilities::getDayOfWeekShortForNumber(2)); + $this->assertSame('Wed', Utilities::getDayOfWeekShortForNumber(3)); + $this->assertSame('Thu', Utilities::getDayOfWeekShortForNumber(4)); + $this->assertSame('Fri', Utilities::getDayOfWeekShortForNumber(5)); + $this->assertSame('Sat', Utilities::getDayOfWeekShortForNumber(6)); + } + + /** + * Test getDayOfWeekShortForNumber returns correct day names. + */ + public function test_getDayOfWeekShortForNumber_gt7(): void + { + $this->assertSame('Sat', Utilities::getDayOfWeekShortForNumber(13)); + } + + /** + * Test getDayOfWeekShortForNumber_noi18n returns correct day names. + */ + public function test_getDayOfWeekShortForNumber_noI18n(): void + { + $this->assertSame('Sun', Utilities::getDayOfWeekShortForNumber_noI18n(0)); + $this->assertSame('Mon', Utilities::getDayOfWeekShortForNumber_noI18n(1)); + $this->assertSame('Tue', Utilities::getDayOfWeekShortForNumber_noI18n(2)); + $this->assertSame('Wed', Utilities::getDayOfWeekShortForNumber_noI18n(3)); + $this->assertSame('Thu', Utilities::getDayOfWeekShortForNumber_noI18n(4)); + $this->assertSame('Fri', Utilities::getDayOfWeekShortForNumber_noI18n(5)); + $this->assertSame('Sat', Utilities::getDayOfWeekShortForNumber_noI18n(6)); + } + + /** + * Test getDayOfWeekShortForNumber_noI18n returns correct day names. + */ + public function test_getDayOfWeekShortForNumber_noI18n_gt7(): void + { + $this->assertSame('Sat', Utilities::getDayOfWeekShortForNumber_noI18n(13)); + } + + /** + * Test getTimeOfDayTermForTime returns correct terms. + */ + public function test_getTimeOfDayTermForTime(): void + { + $this->assertSame('Early Morning', Utilities::getTimeOfDayTermForTime(new DateTime('05:30'))); + $this->assertSame('Morning', Utilities::getTimeOfDayTermForTime(new DateTime('08:30'))); + $this->assertSame('Midday', Utilities::getTimeOfDayTermForTime(new DateTime('12:30'))); + $this->assertSame('Afternoon', Utilities::getTimeOfDayTermForTime(new DateTime('13:15'))); + $this->assertSame('Evening', Utilities::getTimeOfDayTermForTime(new DateTime('19:45'))); + $this->assertSame('Night', Utilities::getTimeOfDayTermForTime(new DateTime('21:00'))); + $this->assertSame('Late Night', Utilities::getTimeOfDayTermForTime(new DateTime('23:00'))); + } + + /** + * Test getTimeOfDayTermForTime_noI18n returns correct terms. + */ + public function test_getTimeOfDayTermForTime_noI18n(): void + { + $this->assertSame('Early Morning', Utilities::getTimeOfDayTermForTime_noI18n(new DateTime('05:30'))); + $this->assertSame('Morning', Utilities::getTimeOfDayTermForTime_noI18n(new DateTime('08:30'))); + $this->assertSame('Midday', Utilities::getTimeOfDayTermForTime_noI18n(new DateTime('12:30'))); + $this->assertSame('Afternoon', Utilities::getTimeOfDayTermForTime_noI18n(new DateTime('13:15'))); + $this->assertSame('Evening', Utilities::getTimeOfDayTermForTime_noI18n(new DateTime('19:45'))); + $this->assertSame('Night', Utilities::getTimeOfDayTermForTime_noI18n(new DateTime('21:00'))); + $this->assertSame('Late Night', Utilities::getTimeOfDayTermForTime_noI18n(new DateTime('23:00'))); + } + + + /** + * Test stringArrayToListString converts arrays to list strings. + */ + public function test_stringArrayToListString(): void + { + // Basic test + $this->assertSame( + 'apple, banana & cherry', + Utilities::stringArrayToListString(['apple', 'banana', 'cherry']) + ); + } + + public function test_stringArrayToListString_withLimitAndOthers(): void + { + // Test with limit + $this->assertSame( + 'apple, banana & others', + Utilities::stringArrayToListString(['apple', 'banana', 'cherry', 'date'], 2, true) + ); + + // Test without "and others" explicitly set + $this->assertSame( + 'apple, banana & others', + Utilities::stringArrayToListString(['apple', 'banana', 'cherry'], 2) + ); + } + + public function test_stringArrayToListString_andComma(): void + { + $this->assertSame( + 'John, Paul, George & Ringo; and Peter, James & John', + Utilities::stringArrayToListString( + ['John, Paul, George & Ringo', 'Peter, James & John'] + ) + ); + } + + public function test_stringArrayToListString_single(): void + { + // Test single item + $this->assertSame( + 'apple', + Utilities::stringArrayToListString(['apple']) + ); + } + + public function test_stringArrayToListString_empty(): void + { + + // Test empty array + $this->assertSame( + '', + Utilities::stringArrayToListString([]) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..dcfd38ef --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,201 @@ + $callback, + 'priority' => $priority, + 'accepted_args' => $accepted_args + ]; + return true; + } +} + +if (!function_exists('apply_filters')) { + function apply_filters($hook, ...$args) { + global $_wp_filters; + if (!isset($_wp_filters) || !isset($_wp_filters[$hook])) { + return $args[0] ?? null; + } + + $value = $args[0] ?? null; + + // Sort by priority + usort($_wp_filters[$hook], function($a, $b) { + return $a['priority'] <=> $b['priority']; + }); + + foreach ($_wp_filters[$hook] as $filter) { + $callback_args = array_slice($args, 0, $filter['accepted_args']); + $value = call_user_func_array($filter['callback'], $callback_args); + $args[0] = $value; // Update first arg for next filter + } + + return $value; + } +} + +if (!function_exists('remove_all_filters')) { + function remove_all_filters($hook, $priority = false) { + global $_wp_filters; + if (!isset($_wp_filters)) { + return true; + } + if ($priority === false) { + unset($_wp_filters[$hook]); + } else { + if (isset($_wp_filters[$hook])) { + $_wp_filters[$hook] = array_filter($_wp_filters[$hook], function($filter) use ($priority) { + return $filter['priority'] != $priority; + }); + } + } + return true; + } +} + +// Mock other WordPress functions used in tests +if (!function_exists('is_admin')) { + function is_admin() { + return false; + } +} + +if (!function_exists('current_datetime')) { + function current_datetime() { + return new \DateTimeImmutable('2025-11-12 21:00:00', new \DateTimeZone('UTC')); + } +} + +if (!function_exists('wp_date')) { + function wp_date($format, $timestamp = null) { + if ($timestamp === null) { + $timestamp = time(); + } + return date($format, $timestamp); + } +} + +if (!function_exists('date_i18n')) { + function date_i18n($format, $timestamp = null) { + if ($timestamp === null) { + $timestamp = time(); + } + return date($format, $timestamp); + } +} + +if (!function_exists('get_option')) { + function get_option($option, $default = false) { + return $default; + } +} + +if (!function_exists('__')) { + function __($text, $domain = 'default') { + return $text; + } +} + +if (!function_exists('_x')) { + function _x($text, $context, $domain = 'default') { + return $text; + } +} + +if (!function_exists('wp_sprintf')) { + function wp_sprintf($pattern, ...$args) { + return sprintf($pattern, ...$args); + } +} + +if (!function_exists('esc_html')) { + function esc_html($text) { + return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + } +} + +if (!function_exists('esc_attr')) { + function esc_attr($text) { + return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + } +} + +if (!function_exists('esc_url')) { + function esc_url($url) { + return $url; + } +} + +if (!function_exists('sanitize_text_field')) { + function sanitize_text_field($str) { + return trim(strip_tags($str)); + } +} + +if (!function_exists('wp_kses_post')) { + function wp_kses_post($data) { + return $data; + } +} + +if (!function_exists('current_user_can')) { + function current_user_can($capability) { + return false; + } +} + +// Load WordPress class mocks +require_once __DIR__ . '/mocks/WP_Error.php'; + +// Autoload Yoast PHPUnit Polyfills +require_once dirname(__DIR__) . '/vendor/yoast/phpunit-polyfills/phpunitpolyfills-autoload.php'; + diff --git a/tests/mocks/WP_Error.php b/tests/mocks/WP_Error.php new file mode 100644 index 00000000..03ef3358 --- /dev/null +++ b/tests/mocks/WP_Error.php @@ -0,0 +1,102 @@ +errors[$code][] = $message; + + if (!empty($data)) { + $this->error_data[$code] = $data; + } + } + + /** + * Retrieve first error code available. + * + * @return string|int Empty string if no error codes are available. + */ + public function get_error_code() + { + $codes = array_keys($this->errors); + return empty($codes) ? '' : $codes[0]; + } + + /** + * Retrieve first error message available. + * + * @param string|int $code Error code to retrieve message for. + * @return string Empty string if no error messages are available. + */ + public function get_error_message($code = '') + { + if (empty($code)) { + $code = $this->get_error_code(); + } + return isset($this->errors[$code]) ? $this->errors[$code][0] : ''; + } + + /** + * Retrieve error data for error code. + * + * @param string|int $code Error code. + * @return mixed Null if $code is invalid. + */ + public function get_error_data($code = '') + { + if (empty($code)) { + $code = $this->get_error_code(); + } + return isset($this->error_data[$code]) ? $this->error_data[$code] : null; + } + + /** + * Add an error. + * + * @param string|int $code Error code. + * @param string $message Error message. + * @param mixed $data Error data. + */ + public function add($code, $message, $data = '') + { + $this->errors[$code][] = $message; + if (!empty($data)) { + $this->error_data[$code] = $data; + } + } + } +} diff --git a/touchpoint-wp.php b/touchpoint-wp.php index fc321ebc..b71e04e1 100644 --- a/touchpoint-wp.php +++ b/touchpoint-wp.php @@ -54,7 +54,10 @@ require_once __DIR__ . "/src/TouchPoint-WP/Geo.php"; require_once __DIR__ . "/src/TouchPoint-WP/Utilities.php"; require_once __DIR__ . "/src/TouchPoint-WP/Utilities/Cleanup.php"; + require_once __DIR__ . "/src/TouchPoint-WP/Utilities/Colors.php"; + require_once __DIR__ . "/src/TouchPoint-WP/Utilities/Database.php"; require_once __DIR__ . "/src/TouchPoint-WP/Utilities/Translation.php"; + require_once __DIR__ . "/src/TouchPoint-WP/Utilities/ImageConversions.php"; require_once __DIR__ . "/src/TouchPoint-WP/Utilities/PersonArray.php"; require_once __DIR__ . "/src/TouchPoint-WP/Utilities/StringableArray.php"; require_once __DIR__ . "/src/TouchPoint-WP/Utilities/NotableAttributes.php";