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";