diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index eda55d0..b2d1f4a 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -1,47 +1,90 @@ -name : PHPUnit Test +name: CI -on : - push : - branches: [ "main" ] +on: + push: + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] permissions: contents: read -jobs : - test: +jobs: + lint: + name: Code Style + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + + - name: Install dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Run Laravel Pint + run: composer lint:test + + analyse: + name: Static Analysis runs-on: ubuntu-latest - steps : - - - name: Set up PHP + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.4' + php-version: "8.4" - - + - name: Install dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + + - name: Run PHPStan + run: composer analyse + + test: + name: PHPUnit Tests + runs-on: ubuntu-latest + + steps: + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + coverage: pcov + + - name: Checkout code uses: actions/checkout@v4 - - - name: Validate composer.json and composer.lock - run : composer validate --strict + - name: Validate composer.json and composer.lock + run: composer validate --strict - - - name: Cache Composer packages - id : composer-cache + - name: Cache Composer packages + id: composer-cache uses: actions/cache@v4 with: - path : vendor - key : ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} restore-keys: | ${{ runner.os }}-php- - - - name: Install dependencies - run : composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist + - name: Install dependencies + run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - - - name: Run test suite - run : composer run-script test + - name: Run test suite with coverage + run: vendor/bin/phpunit tests/ProgressableTest.php --testdox --coverage-text --coverage-clover=coverage.xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage.xml + fail_ci_if_error: false + verbose: true + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index de514fb..36c622e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ vendor/ .vscode/ node_modules/ .phpunit.result.cache +.phpunit.cache/ *.log .DS_Store ai.txt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b658e05 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,71 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] - 2025-01-XX + +### Added +- **Metadata Support**: Store additional data with progress + - `setMetadata(array $metadata)` - Set metadata array + - `getMetadata()` - Get all metadata + - `addMetadata(string $key, mixed $value)` - Add single metadata value + - `getMetadataValue(string $key, mixed $default)` - Get single metadata value +- **Status Messages**: Attach status messages to progress + - `setStatusMessage(?string $message)` - Set status message + - `getStatusMessage()` - Get status message +- **Event Callbacks**: React to progress changes + - `onProgressChange(callable $callback)` - Called on progress change with `(float $new, float $old, static $instance)` + - `onComplete(callable $callback)` - Called when progress reaches 100% with `(static $instance)` +- **Progress Helpers**: + - `incrementLocalProgress(float $amount = 1)` - Increment/decrement progress + - `isComplete()` - Check if local progress is 100% + - `isOverallComplete()` - Check if overall progress is 100% + - `removeLocalFromOverall()` - Remove instance from overall calculation +- **Precision Configuration**: + - `setPrecision(int $precision)` - Set decimal precision + - `getPrecision()` - Get current precision + - Configurable default precision via `config/progressable.php` +- **CI/CD Improvements**: + - PHPStan static analysis (level 5) with Larastan + - Laravel Pint code style enforcement + - Code coverage with pcov and Codecov integration + - Parallel CI jobs for lint, analyse, and test +- **Documentation**: + - CHANGELOG.md with full history + - CONTRIBUTING.md with guidelines + - Comprehensive README with API tables + - Codecov badge in README + +### Changed +- `getLocalProgress()` now accepts `?int` (null uses configured default precision) +- `getOverallProgress()` now accepts `?int` (null uses configured default precision) +- Progress data now stores metadata and messages alongside progress value +- Improved test suite with 35 tests and 70+ assertions + +### Fixed +- README.md incorrect method names (`updateLocalProgress` -> `setLocalProgress`) +- README.md incorrect imports (`use Verseles\Progressable` -> `use Verseles\Progressable\Progressable`) +- Config comment referencing wrong class name (`FullProgress` -> `Progressable`) +- Added missing return type to `getOverallUniqueName()` +- Optimized `setLocalKey()` to avoid duplicate storage calls + +## [1.0.0] - Previous Release + +### Added +- Initial release with core progress tracking functionality +- `setOverallUniqueName()` - Set progress group identifier +- `setLocalProgress()` - Update instance progress +- `getLocalProgress()` - Get instance progress +- `getOverallProgress()` - Get average progress of all instances +- `resetLocalProgress()` - Reset instance to 0 +- `resetOverallProgress()` - Clear all progress data +- `setCustomSaveData()` / `setCustomGetData()` - Custom storage callbacks +- `setTTL()` / `getTTL()` - Cache TTL configuration +- `setPrefixStorageKey()` - Custom cache key prefix +- `setLocalKey()` / `getLocalKey()` - Custom instance identifiers +- Laravel service provider with auto-discovery +- Support for Laravel 11 and 12 +- PHP 8.4 requirement diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6570b60 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing to Progressable + +First off, thank you for considering contributing to Progressable! + +## Development Setup + +1. Clone the repository: +```bash +git clone https://github.com/verseles/progressable.git +cd progressable +``` + +2. Install dependencies: +```bash +composer install +``` + +3. Run tests to make sure everything works: +```bash +composer test +``` + +## Code Quality Tools + +This project uses several tools to maintain code quality: + +### PHPUnit Tests +```bash +composer test +``` + +### Laravel Pint (Code Style) +```bash +# Check for style issues +composer lint:test + +# Fix style issues automatically +composer lint +``` + +### PHPStan (Static Analysis) +```bash +composer analyse +``` + +## Pull Request Process + +1. Fork the repository and create your branch from `main` +2. Make your changes +3. Ensure all tests pass: `composer test` +4. Ensure code style is correct: `composer lint:test` +5. Ensure static analysis passes: `composer analyse` +6. Update documentation if needed (README.md, CHANGELOG.md) +7. Submit your pull request + +## Coding Standards + +- Follow PSR-12 coding standards (enforced by Laravel Pint) +- Write tests for new features +- Keep methods focused and small +- Use descriptive variable and method names +- Add PHPDoc blocks for public methods + +## Adding New Features + +When adding new features: + +1. Add the feature implementation in `src/Progressable.php` +2. Add tests in `tests/ProgressableTest.php` +3. Update `README.md` with usage examples +4. Add entry to `CHANGELOG.md` under `[Unreleased]` + +## Reporting Bugs + +When reporting bugs, please include: + +- PHP version +- Laravel version (if applicable) +- Progressable version +- Steps to reproduce +- Expected behavior +- Actual behavior + +## Questions? + +Feel free to open an issue for any questions about contributing. diff --git a/README.md b/README.md index 503c933..03f57d7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -# Progressable 🚀 +# Progressable A Laravel [(not only)](#without-laravel) package to track and manage progress for different tasks or processes. -![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/verseles/progressable/phpunit.yml?style=for-the-badge&label=PHPUnit) +![CI](https://img.shields.io/github/actions/workflow/status/verseles/progressable/phpunit.yml?style=for-the-badge&label=CI) +![Codecov](https://img.shields.io/codecov/c/github/verseles/progressable?style=for-the-badge) +![PHP Version](https://img.shields.io/packagist/php-v/verseles/progressable?style=for-the-badge) ## Installation @@ -24,9 +26,9 @@ The published config file provides the following options: ```php return [ - 'ttl' => env('PROGRESSABLE_TTL', 1140), // Default cache time-to-live (in minutes) - - 'prefix' => env('PROGRESSABLE_PREFIX', 'progressable'), // Cache key prefix + 'ttl' => env('PROGRESSABLE_TTL', 1140), // Cache TTL in minutes (default: 19 hours) + 'prefix' => env('PROGRESSABLE_PREFIX', 'progressable'), // Cache key prefix + 'precision' => env('PROGRESSABLE_PRECISION', 2), // Default decimal precision ]; ``` @@ -36,13 +38,14 @@ This package provides a main trait: `Progressable`. ### With Laravel -The `Progressable` trait can be used in any class that needs to track progress. It provides two main methods: `updateLocalProgress` and `getLocalProgress`. +The `Progressable` trait can be used in any class that needs to track progress. + +"Local" refers to the progress of your class/model/etc, while "Overall" represents the average of all Progressable instances using the same unique name. -"Local" refers to the progress of your class/model/etc, while "Overall" represents the sum of all Progressable classes using the same key name. +### Basic Example -### Example ```php -use Verseles\Progressable; +use Verseles\Progressable\Progressable; class MyFirstTask { @@ -57,89 +60,153 @@ class MyFirstTask { foreach (range(1, 100) as $value) { $this->setLocalProgress($value); - usleep(100000); // Sleep for 100 milliseconds echo "Overall Progress: " . $this->getOverallProgress() . "%" . PHP_EOL; } } } ``` +### Available Methods + +#### Progress Management + +| Method | Description | +|--------|-------------| +| `setOverallUniqueName(string $name)` | Set the progress group identifier | +| `setLocalProgress(float $progress)` | Set progress for this instance (0-100) | +| `getLocalProgress(?int $precision)` | Get current progress (default precision from config) | +| `incrementLocalProgress(float $amount = 1)` | Increment progress by amount (can be negative) | +| `resetLocalProgress()` | Reset instance progress to 0 | +| `getOverallProgress(?int $precision)` | Get average progress of all instances | +| `resetOverallProgress()` | Clear all progress data for the group | +| `removeLocalFromOverall()` | Remove this instance from overall calculation | + +#### Status Checks + +| Method | Description | +|--------|-------------| +| `isComplete()` | Check if local progress is 100% | +| `isOverallComplete()` | Check if overall progress is 100% | + +#### Metadata & Status Messages + +| Method | Description | +|--------|-------------| +| `setStatusMessage(?string $message)` | Set a status message for this instance | +| `getStatusMessage()` | Get the current status message | +| `setMetadata(array $metadata)` | Set metadata array for this instance | +| `getMetadata()` | Get all metadata | +| `addMetadata(string $key, mixed $value)` | Add/update a single metadata value | +| `getMetadataValue(string $key, mixed $default)` | Get a single metadata value | + +#### Event Callbacks + +| Method | Description | +|--------|-------------| +| `onProgressChange(callable $callback)` | Called when progress changes: `fn($new, $old, $instance)` | +| `onComplete(callable $callback)` | Called when progress reaches 100%: `fn($instance)` | + +#### Configuration + +| Method | Description | +|--------|-------------| +| `setTTL(int $minutes)` | Set cache time-to-live | +| `getTTL()` | Get current TTL | +| `setPrecision(int $decimals)` | Set decimal precision for progress values | +| `getPrecision()` | Get current precision | +| `setPrefixStorageKey(string $prefix)` | Set custom cache key prefix (before setting unique name) | +| `setLocalKey(string $key)` | Set custom identifier for this instance | +| `getLocalKey()` | Get current instance identifier | + +#### Custom Storage (for non-Laravel usage) + +| Method | Description | +|--------|-------------| +| `setCustomSaveData(callable $callback)` | Set custom save callback | +| `setCustomGetData(callable $callback)` | Set custom get callback | + +### Example with Callbacks and Metadata + ```php -use Verseles\Progressable; +use Verseles\Progressable\Progressable; -class MySecondTask +class FileProcessor { use Progressable; - public function __construct() + public function process(array $files) { - $this->setOverallUniqueName('my-job'); - } + $this->setOverallUniqueName('file-processing') + ->resetOverallProgress() + ->onProgressChange(fn($new, $old) => logger("Progress: {$old}% -> {$new}%")) + ->onComplete(fn() => logger("All files processed!")); - public function run() - { - foreach (range(1, 100) as $value) { - $this->setLocalProgress($value); - usleep(100000); // Sleep for 100 milliseconds - echo "Overall Progress: " . $this->getOverallProgress() . "%" . PHP_EOL; + $increment = 100 / count($files); + + foreach ($files as $index => $file) { + $this->setStatusMessage("Processing: {$file}") + ->addMetadata('current_file', $file) + ->addMetadata('files_processed', $index + 1); + + $this->processFile($file); + $this->incrementLocalProgress($increment); } } } ``` -- Use `setOverallUniqueName` to associate the progress with a specific overall progress instance. -- `setLocalProgress` updates the progress for the current instance. -- `getLocalProgress` retrieves the current progress. -- `getOverallProgress` retrieves the overall progress data. -- `resetOverallProgress` resets the overall progress (recommended after setting the unique name for the first time). - -The progress value ranges from 0 to 100. - ### Without Laravel -You can use the `Progressable` trait without Laravel by providing custom save and get data methods. - -### Example +You can use the `Progressable` trait without Laravel by providing custom save and get data callbacks: ```php -$overallUniqueName = 'test-without-laravel'; +use Verseles\Progressable\Progressable; -$my_super_storage = []; +$storage = []; -$saveCallback = function ($key, $data, $ttl) use (&$my_super_storage) { - $my_super_storage[$key] = $data; +$saveCallback = function ($key, $data, $ttl) use (&$storage) { + $storage[$key] = $data; }; -$getCallback = function ($key) use (&$my_super_storage) { - return $my_super_storage[$key] ?? []; +$getCallback = function ($key) use (&$storage) { + return $storage[$key] ?? []; }; +$obj = new class { use Progressable; }; +$obj + ->setCustomSaveData($saveCallback) + ->setCustomGetData($getCallback) + ->setOverallUniqueName('my-task') + ->resetOverallProgress() + ->setLocalProgress(25); -$obj1 = new class { use Progressable; }; -$obj1 - ->setCustomSaveData($saveCallback) - ->setCustomGetData($getCallback) - ->setOverallUniqueName($overallUniqueName) - ->resetOverallProgress() - ->updateLocalProgress(25); - -$obj2 = new class { use Progressable; }; -$obj2 - ->setCustomSaveData($saveCallback) - ->setCustomGetData($getCallback) - ->setOverallUniqueName($overallUniqueName) - ->updateLocalProgress(75); - +echo $obj->getLocalProgress(); // 25 ``` ## Testing -To run the tests, execute the following command: - ```bash -make +# Run tests +composer test + +# Check code style +composer lint:test + +# Fix code style +composer lint + +# Run static analysis +composer analyse ``` +## Contributing + +Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. + +## Changelog + +Please see [CHANGELOG.md](CHANGELOG.md) for recent changes. + ## License The Progressable package is open-sourced software licensed under the [MIT license](./LICENSE.md). diff --git a/composer.json b/composer.json index ee011eb..c68e173 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,10 @@ } }, "scripts": { - "test": "vendor/bin/phpunit tests/ProgressableTest.php --testdox" + "test": "vendor/bin/phpunit tests/ProgressableTest.php --testdox", + "lint": "vendor/bin/pint", + "lint:test": "vendor/bin/pint --test", + "analyse": "vendor/bin/phpstan analyse" }, "config": { "sort-packages": true @@ -41,7 +44,10 @@ "minimum-stability": "dev", "prefer-stable": true, "require-dev": { + "larastan/larastan": "^3.8", + "laravel/pint": "^1.25", "orchestra/testbench": "^9.0", + "phpstan/phpstan": "^2.1", "phpunit/phpunit": "^12" } } diff --git a/composer.lock b/composer.lock index bf16940..dafd128 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d1b660271758a8c2f3df35c8c557ef2c", + "content-hash": "3db87b53cfbba85a71a5233d3918555b", "packages": [ { "name": "brick/math", @@ -5844,6 +5844,137 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.6", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/947083e2dca211a6f12fb1beb67a01e387de9b62", + "reference": "947083e2dca211a6f12fb1beb67a01e387de9b62", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.6" + }, + "time": "2025-03-17T16:59:46+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.8.0", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", + "reference": "d13ef96d652d1b2a8f34f1760ba6bf5b9c98112e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.6.0", + "illuminate/console": "^11.44.2 || ^12.4.1", + "illuminate/container": "^11.44.2 || ^12.4.1", + "illuminate/contracts": "^11.44.2 || ^12.4.1", + "illuminate/database": "^11.44.2 || ^12.4.1", + "illuminate/http": "^11.44.2 || ^12.4.1", + "illuminate/pipeline": "^11.44.2 || ^12.4.1", + "illuminate/support": "^11.44.2 || ^12.4.1", + "php": "^8.2", + "phpstan/phpstan": "^2.1.29" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1", + "orchestra/testbench-core": "^9.12.0 || ^10.1", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.8.0" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2025-10-27T23:09:14+00:00" + }, { "name": "laravel/pail", "version": "v1.2.2", @@ -5922,6 +6053,72 @@ }, "time": "2025-01-28T15:15:15+00:00" }, + { + "name": "laravel/pint", + "version": "v1.25.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/5016e263f95d97670d71b9a987bd8996ade6d8d9", + "reference": "5016e263f95d97670d71b9a987bd8996ade6d8d9", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.87.2", + "illuminate/view": "^11.46.0", + "larastan/larastan": "^3.7.1", + "laravel-zero/framework": "^11.45.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.3.1", + "pestphp/pest": "^2.36.0" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2025-09-19T02:57:12+00:00" + }, { "name": "laravel/tinker", "version": "v2.10.1", @@ -6820,6 +7017,59 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.32", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e126cad1e30a99b137b8ed75a85a676450ebb227", + "reference": "e126cad1e30a99b137b8ed75a85a676450ebb227", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2025-11-11T15:18:17+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "12.3.0", diff --git a/config/progressable.php b/config/progressable.php index 9302814..9da9de5 100644 --- a/config/progressable.php +++ b/config/progressable.php @@ -1,27 +1,40 @@ env('PROGRESSABLE_TTL', 1140), + 'ttl' => env('PROGRESSABLE_TTL', 1140), - /* - |-------------------------------------------------------------------------- - | Cache Prefix - |-------------------------------------------------------------------------- - | - | This option specifies the default prefix for the progress data. - | - */ + /* + |-------------------------------------------------------------------------- + | Cache Prefix + |-------------------------------------------------------------------------- + | + | This option specifies the default prefix for the progress data. + | + */ - 'prefix' => env('PROGRESSABLE_PREFIX', 'progressable'), + 'prefix' => env('PROGRESSABLE_PREFIX', 'progressable'), + + /* + |-------------------------------------------------------------------------- + | Default Precision + |-------------------------------------------------------------------------- + | + | This option specifies the default precision (decimal places) for + | progress values. You can override this per-call by passing a precision + | parameter to getLocalProgress() or getOverallProgress(). + | + */ + + 'precision' => env('PROGRESSABLE_PRECISION', 2), ]; diff --git a/phpstan-bootstrap.php b/phpstan-bootstrap.php new file mode 100644 index 0000000..6efb046 --- /dev/null +++ b/phpstan-bootstrap.php @@ -0,0 +1,14 @@ + + + + + tests + + + + + + src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..6524fb3 --- /dev/null +++ b/pint.json @@ -0,0 +1,13 @@ +{ + "preset": "laravel", + "rules": { + "simplified_null_return": true, + "braces_position": { + "functions_opening_brace": "same_line", + "classes_opening_brace": "same_line" + }, + "align_multiline_comment": { + "comment_type": "phpdocs_only" + } + } +} diff --git a/src/Exceptions/UniqueNameAlreadySetException.php b/src/Exceptions/UniqueNameAlreadySetException.php index a722256..81d617a 100644 --- a/src/Exceptions/UniqueNameAlreadySetException.php +++ b/src/Exceptions/UniqueNameAlreadySetException.php @@ -4,15 +4,13 @@ use Exception; -class UniqueNameAlreadySetException extends Exception -{ - /** - * Exception constructor. - * - * @return void - */ - public function __construct() - { - parent::__construct('The unique name has already been set.'); - } +class UniqueNameAlreadySetException extends Exception { + /** + * Exception constructor. + * + * @return void + */ + public function __construct() { + parent::__construct('The unique name has already been set.'); + } } diff --git a/src/Exceptions/UniqueNameNotSetException.php b/src/Exceptions/UniqueNameNotSetException.php index 6d5f729..5820d03 100644 --- a/src/Exceptions/UniqueNameNotSetException.php +++ b/src/Exceptions/UniqueNameNotSetException.php @@ -4,15 +4,13 @@ use Exception; -class UniqueNameNotSetException extends Exception -{ - /** - * Exception constructor. - * - * @return void - */ - public function __construct() - { - parent::__construct('You must set a unique name before updating progress'); - } +class UniqueNameNotSetException extends Exception { + /** + * Exception constructor. + * + * @return void + */ + public function __construct() { + parent::__construct('You must set a unique name before updating progress'); + } } diff --git a/src/Progressable.php b/src/Progressable.php index 8cb9cec..a12076c 100644 --- a/src/Progressable.php +++ b/src/Progressable.php @@ -6,328 +6,500 @@ use Verseles\Progressable\Exceptions\UniqueNameAlreadySetException; use Verseles\Progressable\Exceptions\UniqueNameNotSetException; -trait Progressable -{ - /** - * The unique name of the overall progress. - * - * @var string - */ - protected string $overallUniqueName; - - /** - * The progress value for this instance. - * - * @var float - */ - protected float $progress = 0; - - /** - * The callback function for saving cache data. - * - * @var callable|null - */ - protected $customSaveData = null; - - /** - * The callback function for retrieving cache data. - * - * @var callable|null - */ - protected $customGetData = null; - - /** - * The default cache time-to-live in minutes. - * - * @var int - */ - protected int $defaultTTL = 1140; - - /** - * If you want to override the default cache time-to-live in minutes, - * - * @var null|int - */ - protected $customTTL = null; - - protected string|null $localKey = null; - - protected string $defaultPrefixStorageKey = "progressable"; - - protected $customPrefixStorageKey = null; - - /** - * Set the callback function for saving cache data. - * - * @param callable $callback - * @return $this - */ - public function setCustomSaveData(callable $callback): static - { - $this->customSaveData = $callback; - return $this; - } - - /** - * Set the callback function for retrieving cache data. - * - * @param callable $callback - * @return $this - */ - public function setCustomGetData(callable $callback): static - { - $this->customGetData = $callback; - return $this; - } - - /** - * Get the overall progress for the unique name. - * - * @param int $precision The precision of the overall progress - * @return float - */ - public function getOverallProgress(int $precision = 2): float - { - $progressData = $this->getOverallProgressData(); - - $totalProgress = array_sum(array_column($progressData, "progress")); - $totalCount = count($progressData); - - if ($totalCount === 0) { - return 0; - } - - return round($totalProgress / $totalCount, $precision, PHP_ROUND_HALF_ODD); - } - - /** - * Get the overall progress data from the storage. - * - * @return array - */ - public function getOverallProgressData(): array - { - if ($this->customGetData !== null) { - return call_user_func($this->customGetData, $this->getStorageKeyName()); - } - - return Cache::get($this->getStorageKeyName(), []); - } - - /** - * Get the cache key for the unique name. - * - * @return string - */ - protected function getStorageKeyName(): string - { - return $this->getPrefixStorageKey() . '_' . $this->getOverallUniqueName(); - } - - /** - * Retrieve the prefix storage key for the PHP function. - * - * @return string - */ - protected function getPrefixStorageKey(): string - { - return $this->customPrefixStorageKey ?? config("progressable.prefix", $this->defaultPrefixStorageKey); - } - - /** - * Get the overall unique name. - * - * @return string the overall unique name - * @throws UniqueNameNotSetException If the overall unique name is not set - */ - public function getOverallUniqueName() - { - if (!isset($this->overallUniqueName) || empty($this->overallUniqueName)) { - throw new UniqueNameNotSetException(); - } - - return $this->overallUniqueName; - } - - /** - * Set the unique name of the overall progress. - * - * @param string $overallUniqueName - * @return $this - */ - public function setOverallUniqueName(string $overallUniqueName): static - { - $this->overallUniqueName = $overallUniqueName; - - $this->makeSureLocalIsPartOfTheCalc(); - - return $this; - } - - /** - * @return void - * @throws UniqueNameNotSetException - */ - public function makeSureLocalIsPartOfTheCalc(): void - { - if ($this->getLocalProgress(0) == 0) { - // This make sure that the class who called this method will be part of the overall progress calculation - $this->resetLocalProgress(); - } - } - - /** - * Get the progress value for this instance - * - * @param int $precision The precision of the local progress - * @return float - */ - public function getLocalProgress(int $precision = 2): float - { - return round($this->progress, $precision, PHP_ROUND_HALF_ODD); - } - - /** - * @return static - * @throws UniqueNameNotSetException - */ - public function resetLocalProgress(): static - { - return $this->setLocalProgress(0); - } - - /** - * Update the progress value for this instance. - * - * @param float $progress - * @return $this - * @throws UniqueNameNotSetException - */ - public function setLocalProgress(float $progress): static - { - $this->progress = max(0, min(100, $progress)); - - return $this->updateLocalProgressData($this->progress); - } - - /** - * Update the progress data in storage. - * - * @return static - */ - protected function updateLocalProgressData(float $progress): static - { - $progressData = $this->getOverallProgressData(); - - $progressData[$this->getLocalKey()] = [ - "progress" => $progress, - ]; - - return $this->saveOverallProgressData($progressData); - } - - /** - * Get the cache identifier for this instance. - * - * @return string - */ - public function getLocalKey(): string - { - return $this->localKey ?? get_class($this) . "@" . spl_object_hash($this); - } - - /** - * Set the local key - * - * @param string $name - * @return $this - */ - public function setLocalKey(string $name): static - { - if (isset($this->getOverallProgressData()[$this->getLocalKey()])) { - // Rename the local key preserving the data - $overallProgressData = $this->getOverallProgressData(); - $overallProgressData[$name] = $overallProgressData[$this->getLocalKey()]; - unset($overallProgressData[$this->getLocalKey()]); - $this->saveOverallProgressData($overallProgressData); - } - - $this->localKey = $name; - - $this->makeSureLocalIsPartOfTheCalc(); - - return $this; - } - - /** - * Save the overall progress data to the storage. - * - * @param array $progressData - * @return static - */ - protected function saveOverallProgressData(array $progressData): static - { - if ($this->customSaveData !== null) { - call_user_func( - $this->customSaveData, - $this->getStorageKeyName(), - $progressData, - $this->getTTL() - ); - } else { - Cache::put($this->getStorageKeyName(), $progressData, $this->getTTL()); - } - - return $this; - } - - /** - * Get the storage time-to-live in minutes. - * - * @return int - */ - public function getTTL(): int - { - return $this->customTTL ?? config("progressable.ttl", $this->defaultTTL); - } - - /** - * Reset the overall progress. - * - * @return static - */ - public function resetOverallProgress(): static - { - return $this->saveOverallProgressData([]); - } - - /** - * Set the prefix storage key. - * - * @param string $prefixStorageKey The prefix storage key to set. - * @throw UniqueNameAlreadySetException If the unique name has already been set - * @return static - */ - public function setPrefixStorageKey(string $prefixStorageKey): static - { - if (isset($this->overallUniqueName)) { - throw new UniqueNameAlreadySetException(); - } - - $this->customPrefixStorageKey = $prefixStorageKey; - - return $this; - } - - /** - * Set the time-to-live for the object. - * - * @param int $defaultTTL The time-to-live value to set - * @return static - */ - public function setTTL(int $TTL): static - { - $this->customTTL = $TTL; - return $this; - } +trait Progressable { + /** + * The unique name of the overall progress. + */ + protected string $overallUniqueName; + + /** + * The progress value for this instance. + */ + protected float $progress = 0; + + /** + * The callback function for saving cache data. + * + * @var callable|null + */ + protected mixed $customSaveData = null; + + /** + * The callback function for retrieving cache data. + * + * @var callable|null + */ + protected mixed $customGetData = null; + + /** + * The default cache time-to-live in minutes. + */ + protected int $defaultTTL = 1140; + + /** + * If you want to override the default cache time-to-live in minutes, + */ + protected ?int $customTTL = null; + + /** + * The local key identifier for this instance. + */ + protected ?string $localKey = null; + + /** + * The default prefix for storage keys. + */ + protected string $defaultPrefixStorageKey = 'progressable'; + + /** + * Custom prefix for storage keys. + */ + protected ?string $customPrefixStorageKey = null; + + /** + * The default precision for progress values. + */ + protected int $defaultPrecision = 2; + + /** + * Custom precision for progress values. + */ + protected ?int $customPrecision = null; + + /** + * Metadata associated with this progress instance. + */ + protected array $metadata = []; + + /** + * Status message for this progress instance. + */ + protected ?string $statusMessage = null; + + /** + * Callback to be called when progress changes. + * + * @var callable|null + */ + protected mixed $onProgressChange = null; + + /** + * Callback to be called when progress completes. + * + * @var callable|null + */ + protected mixed $onComplete = null; + + /** + * Set the callback function for saving cache data. + * + * @return $this + */ + public function setCustomSaveData(callable $callback): static { + $this->customSaveData = $callback; + + return $this; + } + + /** + * Set the callback function for retrieving cache data. + * + * @return $this + */ + public function setCustomGetData(callable $callback): static { + $this->customGetData = $callback; + + return $this; + } + + /** + * Get the overall progress for the unique name. + * + * @param int|null $precision The precision of the overall progress (null uses configured default) + */ + public function getOverallProgress(?int $precision = null): float { + $precision = $precision ?? $this->getPrecision(); + $progressData = $this->getOverallProgressData(); + + $totalProgress = array_sum(array_column($progressData, 'progress')); + $totalCount = count($progressData); + + if ($totalCount === 0) { + return 0; + } + + return round($totalProgress / $totalCount, $precision, PHP_ROUND_HALF_ODD); + } + + /** + * Get the overall progress data from the storage. + */ + public function getOverallProgressData(): array { + if ($this->customGetData !== null) { + return call_user_func($this->customGetData, $this->getStorageKeyName()); + } + + return Cache::get($this->getStorageKeyName(), []); + } + + /** + * Get the cache key for the unique name. + */ + protected function getStorageKeyName(): string { + return $this->getPrefixStorageKey().'_'.$this->getOverallUniqueName(); + } + + /** + * Retrieve the prefix storage key for the PHP function. + */ + protected function getPrefixStorageKey(): string { + return $this->customPrefixStorageKey ?? config('progressable.prefix', $this->defaultPrefixStorageKey); + } + + /** + * Get the overall unique name. + * + * @return string The overall unique name + * + * @throws UniqueNameNotSetException If the overall unique name is not set + */ + public function getOverallUniqueName(): string { + if (! isset($this->overallUniqueName) || empty($this->overallUniqueName)) { + throw new UniqueNameNotSetException; + } + + return $this->overallUniqueName; + } + + /** + * Set the unique name of the overall progress. + * + * @return $this + */ + public function setOverallUniqueName(string $overallUniqueName): static { + $this->overallUniqueName = $overallUniqueName; + + $this->makeSureLocalIsPartOfTheCalc(); + + return $this; + } + + /** + * @throws UniqueNameNotSetException + */ + public function makeSureLocalIsPartOfTheCalc(): void { + if ($this->getLocalProgress(0) == 0) { + // This make sure that the class who called this method will be part of the overall progress calculation + $this->resetLocalProgress(); + } + } + + /** + * Get the progress value for this instance + * + * @param int|null $precision The precision of the local progress (null uses configured default) + */ + public function getLocalProgress(?int $precision = null): float { + $precision = $precision ?? $this->getPrecision(); + + return round($this->progress, $precision, PHP_ROUND_HALF_ODD); + } + + /** + * @throws UniqueNameNotSetException + */ + public function resetLocalProgress(): static { + return $this->setLocalProgress(0); + } + + /** + * Increment the local progress by a given amount. + * + * @param float $amount The amount to increment (can be negative to decrement) + * + * @throws UniqueNameNotSetException + */ + public function incrementLocalProgress(float $amount = 1): static { + return $this->setLocalProgress($this->progress + $amount); + } + + /** + * Check if the local progress is complete (100%). + */ + public function isComplete(): bool { + return $this->progress >= 100; + } + + /** + * Check if the overall progress is complete (100%). + */ + public function isOverallComplete(): bool { + return $this->getOverallProgress(0) >= 100; + } + + /** + * Remove this instance from the overall progress calculation. + * + * + * @throws UniqueNameNotSetException + */ + public function removeLocalFromOverall(): static { + $progressData = $this->getOverallProgressData(); + $localKey = $this->getLocalKey(); + + if (isset($progressData[$localKey])) { + unset($progressData[$localKey]); + $this->saveOverallProgressData($progressData); + } + + $this->progress = 0; + + return $this; + } + + /** + * Update the progress value for this instance. + * + * @return $this + * + * @throws UniqueNameNotSetException + */ + public function setLocalProgress(float $progress): static { + $oldProgress = $this->progress; + $this->progress = max(0, min(100, $progress)); + + $this->updateLocalProgressData($this->progress); + + // Fire progress change callback + if ($this->onProgressChange !== null && $oldProgress !== $this->progress) { + call_user_func($this->onProgressChange, $this->progress, $oldProgress, $this); + } + + // Fire complete callback + if ($this->onComplete !== null && $this->progress >= 100 && $oldProgress < 100) { + call_user_func($this->onComplete, $this); + } + + return $this; + } + + /** + * Update the progress data in storage. + */ + protected function updateLocalProgressData(float $progress): static { + $progressData = $this->getOverallProgressData(); + + $localData = [ + 'progress' => $progress, + ]; + + if ($this->statusMessage !== null) { + $localData['message'] = $this->statusMessage; + } + + if (! empty($this->metadata)) { + $localData['metadata'] = $this->metadata; + } + + $progressData[$this->getLocalKey()] = $localData; + + return $this->saveOverallProgressData($progressData); + } + + /** + * Get the cache identifier for this instance. + */ + public function getLocalKey(): string { + return $this->localKey ?? get_class($this).'@'.spl_object_hash($this); + } + + /** + * Set the local key + * + * @param string $name The new local key name + */ + public function setLocalKey(string $name): static { + $currentKey = $this->getLocalKey(); + $overallProgressData = $this->getOverallProgressData(); + + if (isset($overallProgressData[$currentKey])) { + // Rename the local key preserving the data + $overallProgressData[$name] = $overallProgressData[$currentKey]; + unset($overallProgressData[$currentKey]); + $this->saveOverallProgressData($overallProgressData); + } + + $this->localKey = $name; + + $this->makeSureLocalIsPartOfTheCalc(); + + return $this; + } + + /** + * Save the overall progress data to the storage. + */ + protected function saveOverallProgressData(array $progressData): static { + if ($this->customSaveData !== null) { + call_user_func( + $this->customSaveData, + $this->getStorageKeyName(), + $progressData, + $this->getTTL() + ); + } else { + Cache::put($this->getStorageKeyName(), $progressData, $this->getTTL()); + } + + return $this; + } + + /** + * Get the storage time-to-live in minutes. + */ + public function getTTL(): int { + return $this->customTTL ?? config('progressable.ttl', $this->defaultTTL); + } + + /** + * Reset the overall progress. + */ + public function resetOverallProgress(): static { + return $this->saveOverallProgressData([]); + } + + /** + * Set the prefix storage key. + * + * @param string $prefixStorageKey The prefix storage key to set. + * + * @throw UniqueNameAlreadySetException If the unique name has already been set + */ + public function setPrefixStorageKey(string $prefixStorageKey): static { + if (isset($this->overallUniqueName)) { + throw new UniqueNameAlreadySetException; + } + + $this->customPrefixStorageKey = $prefixStorageKey; + + return $this; + } + + /** + * Set the time-to-live for the object. + * + * @param int $TTL The time-to-live value to set in minutes + */ + public function setTTL(int $TTL): static { + $this->customTTL = $TTL; + + return $this; + } + + /** + * Get the precision for progress values. + */ + public function getPrecision(): int { + return $this->customPrecision ?? config('progressable.precision', $this->defaultPrecision); + } + + /** + * Set the precision for progress values. + * + * @param int $precision The number of decimal places + */ + public function setPrecision(int $precision): static { + $this->customPrecision = max(0, $precision); + + return $this; + } + + /** + * Set the status message for this progress instance. + */ + public function setStatusMessage(?string $message): static { + $this->statusMessage = $message; + + // Update storage with new message if we have a unique name + if (isset($this->overallUniqueName)) { + $this->updateLocalProgressData($this->progress); + } + + return $this; + } + + /** + * Get the status message for this progress instance. + */ + public function getStatusMessage(): ?string { + return $this->statusMessage; + } + + /** + * Set metadata for this progress instance. + * + * @param array $metadata + */ + public function setMetadata(array $metadata): static { + $this->metadata = $metadata; + + // Update storage with new metadata if we have a unique name + if (isset($this->overallUniqueName)) { + $this->updateLocalProgressData($this->progress); + } + + return $this; + } + + /** + * Get metadata for this progress instance. + * + * @return array + */ + public function getMetadata(): array { + return $this->metadata; + } + + /** + * Add or update a single metadata value. + */ + public function addMetadata(string $key, mixed $value): static { + $this->metadata[$key] = $value; + + // Update storage with new metadata if we have a unique name + if (isset($this->overallUniqueName)) { + $this->updateLocalProgressData($this->progress); + } + + return $this; + } + + /** + * Get a single metadata value. + */ + public function getMetadataValue(string $key, mixed $default = null): mixed { + return $this->metadata[$key] ?? $default; + } + + /** + * Set a callback to be called when progress changes. + * + * The callback receives: (float $newProgress, float $oldProgress, static $instance) + */ + public function onProgressChange(callable $callback): static { + $this->onProgressChange = $callback; + + return $this; + } + + /** + * Set a callback to be called when progress reaches 100%. + * + * The callback receives: (static $instance) + */ + public function onComplete(callable $callback): static { + $this->onComplete = $callback; + + return $this; + } } diff --git a/src/ProgressableServiceProvider.php b/src/ProgressableServiceProvider.php index a8781fe..953b93f 100644 --- a/src/ProgressableServiceProvider.php +++ b/src/ProgressableServiceProvider.php @@ -4,37 +4,29 @@ use Illuminate\Support\ServiceProvider; -class ProgressableServiceProvider extends ServiceProvider -{ - /** - * Register services. - * - * @return void - */ - public function register(): void - { - $this->mergeConfigFrom(__DIR__.'/../config/progressable.php', 'progressable'); - } +class ProgressableServiceProvider extends ServiceProvider { + /** + * Register services. + */ + public function register(): void { + $this->mergeConfigFrom(__DIR__.'/../config/progressable.php', 'progressable'); + } - /** - * Bootstrap services. - * - * @return void - */ - public function boot(): void - { - if ($this->app->runningInConsole()) { - $this->publishesConfig(); + /** + * Bootstrap services. + */ + public function boot(): void { + if ($this->app->runningInConsole()) { + $this->publishesConfig(); + } } - } - /** - * Publish config file - */ - protected function publishesConfig(): void - { - $this->publishes([ - __DIR__.'/../config/progressable.php' => config_path('progressable.php'), - ], 'config'); - } + /** + * Publish config file + */ + protected function publishesConfig(): void { + $this->publishes([ + __DIR__.'/../config/progressable.php' => config_path('progressable.php'), + ], 'config'); + } } diff --git a/tests/ProgressableTest.php b/tests/ProgressableTest.php index c45dbd2..2a21740 100644 --- a/tests/ProgressableTest.php +++ b/tests/ProgressableTest.php @@ -3,137 +3,470 @@ namespace Verseles\Progressable\Tests; use Orchestra\Testbench\TestCase; +use Verseles\Progressable\Exceptions\UniqueNameAlreadySetException; use Verseles\Progressable\Exceptions\UniqueNameNotSetException; use Verseles\Progressable\Progressable; -class ProgressableTest extends TestCase -{ - use Progressable; - - public function testSetOverallUniqueName() - { - $this->setOverallUniqueName('test'); - $this->assertEquals('test', $this->getOverallUniqueName()); - } - - public function testUpdateLocalProgress() - { - $this->setOverallUniqueName('test'); - $this->setLocalProgress(50); - $this->assertEquals(50, $this->getLocalProgress()); - } - - public function testUpdateLocalProgressBounds() - { - $this->setOverallUniqueName('test'); - $this->setLocalProgress(-10); - $this->assertEquals(0, $this->getLocalProgress()); - $this->setLocalProgress(120); - $this->assertEquals(100, $this->getLocalProgress()); - } - - public function testGetOverallProgress() - { - $uniqueName = 'test'; - $this->setOverallUniqueName($uniqueName); - $this->setLocalProgress(25); - - $obj2 = new class { - use Progressable; - }; - $obj2->setOverallUniqueName($uniqueName); - $obj2->setLocalProgress(75); - - $this->assertEquals(50, $this->getOverallProgress()); - } - - public function testGetOverallProgressData() - { - $this->setOverallUniqueName('test'); - $this->setLocalProgress(50); - - $progressData = $this->getOverallProgressData(); - $this->assertArrayHasKey($this->getLocalKey(), $progressData); - $this->assertEquals(50, $progressData[$this->getLocalKey()]['progress']); - } - - public function testUpdateLocalProgressWithoutUniqueName() - { - $this->overallUniqueName = ''; - $this->expectException(UniqueNameNotSetException::class); - $this->setLocalProgress(50); - } - - public function testResetLocalProgress() - { - $this->setOverallUniqueName('test'); - $this->setLocalProgress(50); - $this->resetLocalProgress(); - $this->assertEquals(0, $this->getLocalProgress()); - } - - public function testResetOverallProgress() - { - $this->setOverallUniqueName('test'); - $this->setLocalProgress(50); - - $obj2 = new class { - use Progressable; - }; - $obj2->setOverallUniqueName('test'); - $obj2->setLocalProgress(75); - - $this->assertNotEquals(0, $this->getOverallProgress()); - $this->resetOverallProgress(); - $this->assertEquals(0, $this->getOverallProgress()); - } - - public function testSetLocalKey() - { - $this->setOverallUniqueName('test'); - $this->setLocalKey('my_custom_key'); - $progressData = $this->getOverallProgressData(); - $this->assertArrayHasKey('my_custom_key', $progressData); - } - - public function testSetPrefixStorageKey() - { - $this->setPrefixStorageKey('custom_prefix'); - $this->setOverallUniqueName('test'); - $this->assertEquals('custom_prefix_test', $this->getStorageKeyName()); - - } - - public function testSetTTL() - { - $this->setOverallUniqueName('test'); - $this->setLocalProgress(50); - $this->setTTL(60); // 1 heure - - $ttl = $this->getTTL(); - $this->assertEquals(60, $ttl); - } - - public function testCustomSaveAndGetData() - { - $storage = []; - - $saveCallback = function ($key, $data, $ttl) use (&$storage) { - $storage[$key] = $data; - }; - - $getCallback = function ($key) use (&$storage) { - return $storage[$key] ?? []; - }; - - $this->setCustomSaveData($saveCallback); - $this->setCustomGetData($getCallback); - - $this->setOverallUniqueName('custom_test'); - $this->setLocalProgress(50); - - $progressData = $this->getOverallProgressData(); - $this->assertArrayHasKey($this->getLocalKey(), $progressData); - $this->assertEquals(50, $progressData[$this->getLocalKey()]['progress']); - } +class ProgressableTest extends TestCase { + use Progressable; + + /** + * Unique test identifier to isolate tests. + */ + private string $testId; + + protected function setUp(): void { + parent::setUp(); + + // Generate unique ID for each test to ensure isolation + $this->testId = uniqid('test_', true); + + // Reset trait properties to default state + $this->progress = 0; + $this->customSaveData = null; + $this->customGetData = null; + $this->customTTL = null; + $this->localKey = null; + $this->customPrefixStorageKey = null; + $this->customPrecision = null; + $this->metadata = []; + $this->statusMessage = null; + $this->onProgressChange = null; + $this->onComplete = null; + + // Unset overallUniqueName to simulate fresh state + unset($this->overallUniqueName); + } + + public function test_set_overall_unique_name(): void { + $uniqueName = 'test_unique_'.$this->testId; + $this->setOverallUniqueName($uniqueName); + $this->assertEquals($uniqueName, $this->getOverallUniqueName()); + } + + public function test_update_local_progress(): void { + $this->setOverallUniqueName('test_progress_'.$this->testId); + $this->setLocalProgress(50); + $this->assertEquals(50, $this->getLocalProgress()); + } + + public function test_update_local_progress_bounds(): void { + $this->setOverallUniqueName('test_bounds_'.$this->testId); + $this->setLocalProgress(-10); + $this->assertEquals(0, $this->getLocalProgress()); + $this->setLocalProgress(120); + $this->assertEquals(100, $this->getLocalProgress()); + } + + public function test_get_overall_progress(): void { + $uniqueName = 'test_overall_'.$this->testId; + $this->setOverallUniqueName($uniqueName); + $this->setLocalProgress(25); + + $obj2 = new class { + use Progressable; + }; + $obj2->setOverallUniqueName($uniqueName); + $obj2->setLocalProgress(75); + + $this->assertEquals(50, $this->getOverallProgress()); + } + + public function test_get_overall_progress_data(): void { + $this->setOverallUniqueName('test_data_'.$this->testId); + $this->setLocalProgress(50); + + $progressData = $this->getOverallProgressData(); + $this->assertArrayHasKey($this->getLocalKey(), $progressData); + $this->assertEquals(50, $progressData[$this->getLocalKey()]['progress']); + } + + public function test_update_local_progress_without_unique_name(): void { + $this->expectException(UniqueNameNotSetException::class); + $this->setLocalProgress(50); + } + + public function test_reset_local_progress(): void { + $this->setOverallUniqueName('test_reset_local_'.$this->testId); + $this->setLocalProgress(50); + $this->resetLocalProgress(); + $this->assertEquals(0, $this->getLocalProgress()); + } + + public function test_reset_overall_progress(): void { + $uniqueName = 'test_reset_overall_'.$this->testId; + $this->setOverallUniqueName($uniqueName); + $this->setLocalProgress(50); + + $obj2 = new class { + use Progressable; + }; + $obj2->setOverallUniqueName($uniqueName); + $obj2->setLocalProgress(75); + + $this->assertNotEquals(0, $this->getOverallProgress()); + $this->resetOverallProgress(); + $this->assertEquals(0, $this->getOverallProgress()); + } + + public function test_set_local_key(): void { + $this->setOverallUniqueName('test_local_key_'.$this->testId); + $this->setLocalKey('my_custom_key'); + $progressData = $this->getOverallProgressData(); + $this->assertArrayHasKey('my_custom_key', $progressData); + } + + public function test_set_local_key_preserves_progress_data(): void { + $this->setOverallUniqueName('test_local_key_rename_'.$this->testId); + $this->setLocalKey('original_key'); + $this->setLocalProgress(75); + + // Verify original key has the progress + $progressData = $this->getOverallProgressData(); + $this->assertArrayHasKey('original_key', $progressData); + $this->assertEquals(75, $progressData['original_key']['progress']); + + // Rename the key + $this->setLocalKey('renamed_key'); + + // Verify data was preserved under new key and old key is removed + $progressData = $this->getOverallProgressData(); + $this->assertArrayHasKey('renamed_key', $progressData); + $this->assertArrayNotHasKey('original_key', $progressData); + $this->assertEquals(75, $progressData['renamed_key']['progress']); + } + + public function test_set_prefix_storage_key(): void { + $this->setPrefixStorageKey('custom_prefix'); + $this->setOverallUniqueName('test_prefix_'.$this->testId); + $this->assertEquals('custom_prefix_test_prefix_'.$this->testId, $this->getStorageKeyName()); + } + + public function test_set_prefix_storage_key_after_unique_name_throws_exception(): void { + $this->setOverallUniqueName('test_prefix_exception_'.$this->testId); + $this->expectException(UniqueNameAlreadySetException::class); + $this->setPrefixStorageKey('custom_prefix'); + } + + public function test_set_ttl(): void { + $this->setOverallUniqueName('test_ttl_'.$this->testId); + $this->setLocalProgress(50); + $this->setTTL(60); // 1 hour + + $ttl = $this->getTTL(); + $this->assertEquals(60, $ttl); + } + + public function test_custom_save_and_get_data(): void { + $storage = []; + + $saveCallback = function ($key, $data, $ttl) use (&$storage) { + $storage[$key] = $data; + }; + + $getCallback = function ($key) use (&$storage) { + return $storage[$key] ?? []; + }; + + $this->setCustomSaveData($saveCallback); + $this->setCustomGetData($getCallback); + + $this->setOverallUniqueName('custom_test_'.$this->testId); + $this->setLocalProgress(50); + + $progressData = $this->getOverallProgressData(); + $this->assertArrayHasKey($this->getLocalKey(), $progressData); + $this->assertEquals(50, $progressData[$this->getLocalKey()]['progress']); + } + + public function test_make_sure_local_is_part_of_the_calc(): void { + $uniqueName = 'test_auto_register_'.$this->testId; + + // When setting unique name, instance should be automatically registered + $this->setOverallUniqueName($uniqueName); + + $progressData = $this->getOverallProgressData(); + $this->assertArrayHasKey($this->getLocalKey(), $progressData); + $this->assertEquals(0, $progressData[$this->getLocalKey()]['progress']); + } + + public function test_get_overall_progress_with_empty_data(): void { + $this->setOverallUniqueName('test_empty_'.$this->testId); + $this->resetOverallProgress(); + + // Should return 0 when no progress data exists + $this->assertEquals(0, $this->getOverallProgress()); + } + + public function test_progress_precision(): void { + $this->setOverallUniqueName('test_precision_'.$this->testId); + $this->setLocalProgress(33.3333); + + $this->assertEquals(33.33, $this->getLocalProgress(2)); + $this->assertEquals(33.333, $this->getLocalProgress(3)); + $this->assertEquals(33, $this->getLocalProgress(0)); + } + + public function test_increment_local_progress(): void { + $this->setOverallUniqueName('test_increment_'.$this->testId); + $this->setLocalProgress(10); + + $this->incrementLocalProgress(5); + $this->assertEquals(15, $this->getLocalProgress()); + + $this->incrementLocalProgress(10); + $this->assertEquals(25, $this->getLocalProgress()); + } + + public function test_increment_local_progress_with_negative(): void { + $this->setOverallUniqueName('test_decrement_'.$this->testId); + $this->setLocalProgress(50); + + $this->incrementLocalProgress(-10); + $this->assertEquals(40, $this->getLocalProgress()); + } + + public function test_increment_local_progress_respects_bounds(): void { + $this->setOverallUniqueName('test_increment_bounds_'.$this->testId); + $this->setLocalProgress(95); + + $this->incrementLocalProgress(10); + $this->assertEquals(100, $this->getLocalProgress()); + + $this->setLocalProgress(5); + $this->incrementLocalProgress(-10); + $this->assertEquals(0, $this->getLocalProgress()); + } + + public function test_is_complete(): void { + $this->setOverallUniqueName('test_is_complete_'.$this->testId); + + $this->setLocalProgress(50); + $this->assertFalse($this->isComplete()); + + $this->setLocalProgress(99.99); + $this->assertFalse($this->isComplete()); + + $this->setLocalProgress(100); + $this->assertTrue($this->isComplete()); + } + + public function test_is_overall_complete(): void { + $uniqueName = 'test_is_overall_complete_'.$this->testId; + $this->setOverallUniqueName($uniqueName); + $this->setLocalProgress(100); + + $obj2 = new class { + use Progressable; + }; + $obj2->setOverallUniqueName($uniqueName); + $obj2->setLocalProgress(50); + + $this->assertFalse($this->isOverallComplete()); + + $obj2->setLocalProgress(100); + $this->assertTrue($this->isOverallComplete()); + } + + public function test_remove_local_from_overall(): void { + $uniqueName = 'test_remove_local_'.$this->testId; + $this->setOverallUniqueName($uniqueName); + $this->setLocalKey('instance_1'); + $this->setLocalProgress(50); + + $obj2 = new class { + use Progressable; + }; + $obj2->setOverallUniqueName($uniqueName); + $obj2->setLocalKey('instance_2'); + $obj2->setLocalProgress(100); + + // Both instances contribute to overall progress + $this->assertEquals(75, $this->getOverallProgress()); + + // Remove first instance + $this->removeLocalFromOverall(); + + // Only second instance remains + $progressData = $this->getOverallProgressData(); + $this->assertArrayNotHasKey('instance_1', $progressData); + $this->assertArrayHasKey('instance_2', $progressData); + $this->assertEquals(100, $this->getOverallProgress()); + + // Local progress should be reset to 0 + $this->assertEquals(0, $this->progress); + } + + public function test_set_precision(): void { + $this->setOverallUniqueName('test_set_precision_'.$this->testId); + $this->setLocalProgress(33.33333); + + // Default precision + $this->assertEquals(33.33, $this->getLocalProgress()); + + // Custom precision + $this->setPrecision(4); + $this->assertEquals(4, $this->getPrecision()); + $this->assertEquals(33.3333, $this->getLocalProgress()); + + // Precision 0 + $this->setPrecision(0); + $this->assertEquals(33, $this->getLocalProgress()); + } + + public function test_get_local_progress_uses_default_precision(): void { + $this->setOverallUniqueName('test_default_precision_'.$this->testId); + $this->setLocalProgress(33.33333); + + // Without parameter, should use default precision (2) + $this->assertEquals(33.33, $this->getLocalProgress()); + + // With explicit null, should also use default + $this->assertEquals(33.33, $this->getLocalProgress(null)); + } + + public function test_get_overall_progress_uses_default_precision(): void { + $this->setOverallUniqueName('test_overall_default_precision_'.$this->testId); + $this->setLocalProgress(33.33333); + + // Without parameter, should use default precision (2) + $this->assertEquals(33.33, $this->getOverallProgress()); + + // With explicit null, should also use default + $this->assertEquals(33.33, $this->getOverallProgress(null)); + } + + public function test_set_status_message(): void { + $this->setOverallUniqueName('test_status_message_'.$this->testId); + $this->setLocalProgress(50); + + $this->setStatusMessage('Processing files...'); + $this->assertEquals('Processing files...', $this->getStatusMessage()); + + // Check it's stored in progress data + $progressData = $this->getOverallProgressData(); + $this->assertEquals('Processing files...', $progressData[$this->getLocalKey()]['message']); + } + + public function test_set_metadata(): void { + $this->setOverallUniqueName('test_metadata_'.$this->testId); + $this->setLocalProgress(50); + + $metadata = ['file' => 'test.txt', 'size' => 1024]; + $this->setMetadata($metadata); + + $this->assertEquals($metadata, $this->getMetadata()); + $this->assertEquals('test.txt', $this->getMetadataValue('file')); + $this->assertEquals(1024, $this->getMetadataValue('size')); + $this->assertNull($this->getMetadataValue('nonexistent')); + $this->assertEquals('default', $this->getMetadataValue('nonexistent', 'default')); + + // Check it's stored in progress data + $progressData = $this->getOverallProgressData(); + $this->assertEquals($metadata, $progressData[$this->getLocalKey()]['metadata']); + } + + public function test_add_metadata(): void { + $this->setOverallUniqueName('test_add_metadata_'.$this->testId); + $this->setLocalProgress(50); + + $this->addMetadata('step', 1); + $this->addMetadata('total', 10); + + $this->assertEquals(1, $this->getMetadataValue('step')); + $this->assertEquals(10, $this->getMetadataValue('total')); + + // Update existing key + $this->addMetadata('step', 2); + $this->assertEquals(2, $this->getMetadataValue('step')); + } + + public function test_on_progress_change_callback(): void { + $this->setOverallUniqueName('test_callback_change_'.$this->testId); + + $callbackCalled = false; + $capturedNew = null; + $capturedOld = null; + + $this->onProgressChange(function ($new, $old, $instance) use (&$callbackCalled, &$capturedNew, &$capturedOld) { + $callbackCalled = true; + $capturedNew = $new; + $capturedOld = $old; + }); + + $this->setLocalProgress(50); + + $this->assertTrue($callbackCalled); + $this->assertEquals(50, $capturedNew); + $this->assertEquals(0, $capturedOld); + } + + public function test_on_progress_change_not_called_when_same_value(): void { + $this->setOverallUniqueName('test_callback_same_'.$this->testId); + $this->setLocalProgress(50); + + $callCount = 0; + $this->onProgressChange(function () use (&$callCount) { + $callCount++; + }); + + // Setting same value should not trigger callback + $this->setLocalProgress(50); + $this->assertEquals(0, $callCount); + + // Setting different value should trigger callback + $this->setLocalProgress(51); + $this->assertEquals(1, $callCount); + } + + public function test_on_complete_callback(): void { + $this->setOverallUniqueName('test_callback_complete_'.$this->testId); + + $completeCalled = false; + $this->onComplete(function ($instance) use (&$completeCalled) { + $completeCalled = true; + }); + + $this->setLocalProgress(50); + $this->assertFalse($completeCalled); + + $this->setLocalProgress(100); + $this->assertTrue($completeCalled); + } + + public function test_on_complete_callback_only_fires_once(): void { + $this->setOverallUniqueName('test_callback_complete_once_'.$this->testId); + + $callCount = 0; + $this->onComplete(function () use (&$callCount) { + $callCount++; + }); + + $this->setLocalProgress(100); + $this->assertEquals(1, $callCount); + + // Setting to 100 again should not trigger (already at 100) + $this->setLocalProgress(100); + $this->assertEquals(1, $callCount); + + // Going down and back up should trigger again + $this->setLocalProgress(50); + $this->setLocalProgress(100); + $this->assertEquals(2, $callCount); + } + + public function test_metadata_and_message_stored_together(): void { + $this->setOverallUniqueName('test_metadata_message_'.$this->testId); + + $this->setStatusMessage('Processing...'); + $this->setMetadata(['step' => 1]); + $this->setLocalProgress(50); + + $progressData = $this->getOverallProgressData(); + $localData = $progressData[$this->getLocalKey()]; + + $this->assertEquals(50, $localData['progress']); + $this->assertEquals('Processing...', $localData['message']); + $this->assertEquals(['step' => 1], $localData['metadata']); + } }