diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..24e1791 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + push: + branches: [ "master", "dev" ] + pull_request: + branches: [ "master", "dev" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + + - name: Run test suite + run: composer test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3fca1a9..4c607e4 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,10 @@ vendor/ # Ignore PHPUnit configuration file (if you're using phpunit.xml for local customizations) phpunit.xml +# Ignore PHPUnit test result output files +.phpunit.cache +coverage-report + # Ignore Composer lock file if you don't want to share it (usually, you want to commit composer.lock) # composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..86ee8fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# ChangeLog + +## v1.1.0 - 2025-12-09 +### Added +- Implemented `__toString()` method for the `Result` class, allowing instances to be converted to strings (e.g. `"$result"` now returns the calculated value). (PR #1) +- Added new `stop()` method to the timer, supporting stopping: (PR #2) + - a specific timer by ID + - **or** the **most recently started timer** when no ID is provided. +- Added new `watch()` static method to replace `run()` for executing and timing callbacks. (PR #4) +- Added new timer utility methods `isStarted()`, `isStopped()`, and `getActiveTimers()` in `TimeTracker` to inspect active and completed timers. (PR #7) +- Added new timer utility methods `lap()`, `getLaps()`, `pause()`, `resume()`, and `inspect()` in `TimeTracker`. (PR #9) + +### Changed +- Replaced `ramsey/uuid` with native PHP functions (`bin2hex(random_bytes(16))`) for generating random IDs. (PR #3) +- Removed the `ramsey/uuid` dependency as it is no longer required. (PR #3) +- Replaced `STATUS_*` string constants with a dedicated `TimerStatus` enum. (PR #5) + +### Fixed +- Prevent duplicate `stop()` calls in `watch()` by adding an `isStopped()` check in the `finally` block. (RP #8) + +### Deprecated +- Marked `end()` as deprecated. It still works for backward compatibility but will be removed in a future major release. (PR #2) +- Marked `run()` as deprecated. It still works for backward compatibility but will be removed in a future major release. (PR #4) \ No newline at end of file diff --git a/README.md b/README.md index f8a9b56..957e756 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,25 @@ - composer ## Installation via Composer -To install, navigate to your project root directory (where `composer.json` is located) and run the following command: -```shell - composer require naingaunglwin-dev/timetracker +> If Composer is not installed, follow the [official guide](https://getcomposer.org/download/). + +1. Create a `composer.json` file at your project root directory (if you don't have one): +```json +{ + "require": { + "naingaunglwin-dev/timetracker": "^1.0" + } +} +``` + +- Run the following command in your terminal from the project's root directory: +```bash +composer install ``` -- If `composer.json` doesn't exits, run this command first, -```shell - composer init + +If you already have `composer.json` file in your project, just run this command in your terminal, +```bash +composer require naingaunglwin-dev/timetracker ``` ## Usage @@ -40,7 +52,7 @@ $tracker->start('test'); echo 'hello world
'; sleep(3); -$tracker->end('test'); +$tracker->stop('test'); echo $tracker->calculate('test') ->get(); @@ -53,18 +65,12 @@ echo $tracker->calculate('test') ### Convert to different unit - By default, the unit is in seconds (s). You can convert to other predefined units like milliseconds (ms), microseconds (us), and more: ```php -start('test'); echo 'hello world
'; sleep(3); -$tracker->end('test'); +$tracker->stop('test'); echo $tracker->calculate('test') ->convert('ms') @@ -78,18 +84,12 @@ echo $tracker->calculate('test') ### Add custom unit - You can define custom units based on seconds (for example, converting seconds to custom units): ```php -start('test'); echo 'hello world
'; sleep(3); -$tracker->end('test'); +$tracker->stop('test'); // Add a custom unit definition (1 second = 10 custom units) $tracker->addUnitDefinition('testunit', '*', 10); @@ -106,18 +106,12 @@ echo $tracker->calculate('test') ### Format output - You can format the output of the calculated time using placeholders: ```php -start('test'); echo 'hello world
'; sleep(3); -$tracker->end('test'); +$tracker->stop('test'); echo $tracker->calculate('test') ->convert('ms') @@ -132,15 +126,6 @@ echo $tracker->calculate('test') ### Time tracking with callback function - You can track time for a callback function and get both the execution time and the result: ```php -$result = \NAL\TimeTracker\TimeTracker::run( - function (Conversation $conv, $time) { - sleep(3); - return $conv->greet($time) . '
do something at ' . $time; - }, - ['time' => 'evening'], //parameters variableName => value - 'ms' // time unit, default is `s` -); - class Conversation { public function greet($time){ @@ -148,7 +133,14 @@ class Conversation } } -var_dump($result); +$watch = \NAL\TimeTracker\TimeTracker::watch( + function (Conversation $conv, $time) { + sleep(3); + return $conv->greet($time) . '
do something at ' . $time; + }, + ['time' => 'evening'], //parameters variableName => value + 'ms' // time unit, default is `s` +); ``` - Example output: ```php @@ -160,3 +152,50 @@ array (size=4) 'unit' => string 'ms' (length=2) 'output' => string 'good evening, do something at evening' (length=37) ``` + +### Checking timer states + +The following methods help you check timer states and get currently active timers. + +#### Check if a timer has started +```php +$tracker->start('download'); + +if ($tracker->isStarted('download')) { + echo "Download timer is started."; +} + +// Output: +// Download timer is started. +``` + +#### Check if a timer has stopped +```php +$tracker->start('process'); + +sleep(1); + +$tracker->stop('process'); + +if ($tracker->isStopped('process')) { + echo "Process timer is stopped."; +} + +// Output: +// Process timer is stopped. +``` + +#### Get currently active timers +```php +$tracker->start('task1'); +$tracker->start('task2'); +$tracker->stop('task1'); + +print_r($tracker->getActiveTimers()); + +// Output: +// Array +// ( +// [0] => task2 +// ) +``` diff --git a/composer.json b/composer.json index 47c0ae6..1a6e8df 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,6 @@ "name": "naingaunglwin-dev/timetracker", "description": "A lightweight time tracker for php", "minimum-stability": "stable", - "version": "1.0.0", "type": "library", "prefer-stable": true, "license": "MIT", @@ -19,10 +18,12 @@ }, "require": { "php": ">=8.3", - "ramsey/uuid": "^4.7", "illuminate/container": "^11.36" }, "require-dev": { "phpunit/phpunit": "^11.5" + }, + "scripts": { + "test": "vendor/bin/phpunit tests" } } diff --git a/src/Exception/NoActivePausedTimerToResume.php b/src/Exception/NoActivePausedTimerToResume.php new file mode 100644 index 0000000..f103257 --- /dev/null +++ b/src/Exception/NoActivePausedTimerToResume.php @@ -0,0 +1,11 @@ + throw new UnsupportedLogic("Unsupported operator '{$operator}' in unit definition."), }; } + + /** + * @return string + */ + public function __toString(): string + { + return $this->calculated; + } } diff --git a/src/TimeTracker.php b/src/TimeTracker.php index 2dd4f8b..a5999ac 100644 --- a/src/TimeTracker.php +++ b/src/TimeTracker.php @@ -5,9 +5,12 @@ use Illuminate\Container\Container; use InvalidArgumentException; use NAL\TimeTracker\Exception\InvalidUnitName; +use NAL\TimeTracker\Exception\NoActivePausedTimerToResume; +use NAL\TimeTracker\Exception\NoActiveTimerToStopException; +use NAL\TimeTracker\Exception\TimerAlreadyPaused; use NAL\TimeTracker\Exception\TimerNotStarted; +use NAL\TimeTracker\Exception\UnmatchedPauseWithoutResume; use NAL\TimeTracker\Exception\UnsupportedLogic; -use Ramsey\Uuid\Uuid; class TimeTracker { @@ -26,25 +29,25 @@ class TimeTracker private array $end = []; /** - * Indicates a timer that has not been started. + * Stores pause times of tracked operations by ID. * - * @var string + * @var array */ - const string STATUS_NOT_STARTED = 'not started'; + private array $pause = []; /** - * Indicates a timer that is in progress. + * Stores resume times of tracked operations by ID. * - * @var string + * @var array */ - const string STATUS_IN_PROGRESS = 'in progress'; + private array $resume = []; /** - * Indicates a timer that has been completed. + * Stores laps of tracked operations by ID. * - * @var string + * @var array */ - const string STATUS_COMPLETED = 'completed'; + private array $laps = []; private Unit $unit; @@ -67,9 +70,14 @@ public function start(string $id): void } /** + * @codeCoverageIgnore + * * Ends a timer with the given ID. * + * @deprecated Use `stop()` instead + * * @param string $id The identifier for the timer. + * * @throws TimerNotStarted If the timer with the given ID has not been started. */ public function end(string $id): void @@ -82,8 +90,190 @@ public function end(string $id): void } /** + * Stops the specified timer or, if no ID is provided, the most recently started timer. + * + * @param string|null $id The identifier of the timer to stop, or null to stop the last started timer. + * + * @throws TimerNotStarted If no timer has been started, or the specified timer ID does not exist. + * @throws \RuntimeException If the specified timer has already been stopped. + * + * @return void + */ + public function stop(?string $id = null): void + { + if (empty($this->start)) { + throw new TimerNotStarted($id); + } + + if ($id === null) { + $id = array_key_last($this->start); + } + + if (!isset($this->start[$id])) { + throw new TimerNotStarted($id); + } + + if (isset($this->end[$id])) { + throw new NoActiveTimerToStopException(); + } + + $this->end[$id] = microtime(true); + } + + /** + * Records a lap time for the specified timer. + * + * A lap represents a checkpoint within an ongoing timer session. Each lap is recorded + * with its timestamp and an optional description for context. + * + * @param string $id The identifier of the timer. + * @param string $description An optional label or note for this lap (default: empty string). + * + * @throws TimerNotStarted If the timer with the given ID has not been started. + * + * @return void + */ + public function lap(string $id, string $description = ''): void + { + if (!isset($this->start[$id])) { + throw new TimerNotStarted($id); + } + + $this->laps[$id][] = [ + 'description' => $description, + 'time' => microtime(true) + ]; + } + + /** + * Retrieves all recorded laps for a given timer. + * + * Each lap contains its timestamp and optional description. + * If no laps have been recorded, an empty array is returned. + * + * @param string $id The identifier of the timer. + * + * @return array A list of laps with their details. + */ + public function getLaps(string $id): array + { + return $this->laps[$id] ?? []; + } + + /** + * Pauses the specified timer. + * + * If the timer is already paused and not yet resumed, this method throws an exception. + * Each pause event is recorded with a timestamp and an optional description. + * + * @param string $id The identifier of the timer. + * @param string $description An optional label or note for this pause (default: empty string). + * + * @throws TimerNotStarted If the timer with the given ID has not been started. + * @throws TimerAlreadyPaused If the timer is already paused. + * + * @return void + */ + public function pause(string $id, string $description = ''): void + { + if (!isset($this->start[$id])) { + throw new TimerNotStarted($id); + } + + if (isset($this->pause[$id]) && count($this->pause[$id]) > count($this->resume[$id] ?? [])) { + throw new TimerAlreadyPaused($id); + } + + $this->pause[$id][] = [ + 'description' => $description, + 'time' => microtime(true) + ]; + } + + /** + * Resumes a previously paused timer. + * + * A timer can only be resumed if it has an active pause entry that has not yet been resumed. + * Each resume event is recorded with a timestamp and an optional description. + * + * @param string $id The identifier of the timer. + * @param string $description An optional label or note for this resume (default: empty string). + * + * @throws TimerNotStarted If the timer with the given ID has not been started. + * @throws NoActivePausedTimerToResume If there is no active pause to resume. + * + * @return void + */ + public function resume(string $id, string $description = ''): void + { + if (!isset($this->start[$id])) { + throw new TimerNotStarted($id); + } + + if (!isset($this->pause[$id]) || count($this->pause[$id]) === count($this->resume[$id] ?? [])) { + throw new NoActivePausedTimerToResume($id); + } + + $this->resume[$id][] = [ + 'description' => $description, + 'time' => microtime(true) + ]; + } + + /** + * Check whether timer with given id is started or not + * + * @param string $id + * + * @return bool + */ + public function isStarted(string $id): bool + { + return array_key_exists($id, $this->start); + } + + /** + * Check whether timer with given id is ended or not + * + * @param string $id + * + * @return bool + */ + public function isStopped(string $id): bool + { + return array_key_exists($id, $this->end); + } + + /** + * Get the current active timers' id + * + * @return array + */ + public function getActiveTimers(): array + { + if ($this->start === []) { + return []; + } + + $ids = array_keys($this->start); + $activeTimers = []; + + foreach ($ids as $id) { + if (!$this->isStopped($id)) { + $activeTimers[] = $id; + } + } + + return $activeTimers; + } + + /** + * @codeCoverageIgnore + * * Executes a callback while tracking its execution time. * + * @deprecated Use `watch` instead + * * @param callable $callback The callback function to execute. * @param array $params Parameters to pass to the callback. * @param string $unit The unit for measuring execution time. @@ -93,7 +283,54 @@ public static function run(callable $callback, array $params = [], string $unit { $timeTracker = new self(); - $randomId = Uuid::uuid4()->toString(); + $randomId = bin2hex(random_bytes(16)); + + $container = new Container(); + + $timeTracker->start($randomId); + + try { + + $output = $container->call($callback, $params); + + } catch (\Throwable $e) { + $timeTracker->stop($randomId); + + throw new \RuntimeException( + $timeTracker->calculate($randomId)->format('Error occurring during executing callback, end in %s%s')->get() . + "\n{$e->getMessage()}", + $e->getCode(), + $e + ); + } finally { + if (!$timeTracker->isStopped($randomId)) { + $timeTracker->stop($randomId); + } + } + + $result = $timeTracker->calculate($randomId); + + return [ + 'result' => $result, + 'time' => $result->convert($unit)->get(), + 'unit' => $unit, + 'output' => $output ?? null + ]; + } + + /** + * Executes a callback while tracking its execution time. + * + * @param callable $callback The callback function to execute. + * @param array $params Parameters to pass to the callback. + * @param string $unit The unit for measuring execution time. + * @return array{result: Result, time: float|int, unit: string, output: mixed} An array containing Result, the execution time, unit, and callback result. + */ + public static function watch(callable $callback, array $params = [], string $unit = 's'): array + { + $timeTracker = new self(); + + $randomId = bin2hex(random_bytes(16)); $container = new Container(); @@ -104,7 +341,7 @@ public static function run(callable $callback, array $params = [], string $unit $output = $container->call($callback, $params); } catch (\Throwable $e) { - $timeTracker->end($randomId); + $timeTracker->stop($randomId); throw new \RuntimeException( $timeTracker->calculate($randomId)->format('Error occurring during executing callback, end in %s%s')->get() . @@ -113,7 +350,9 @@ public static function run(callable $callback, array $params = [], string $unit $e ); } finally { - $timeTracker->end($randomId); + if (!$timeTracker->isStopped($randomId)) { + $timeTracker->stop($randomId); + } } $result = $timeTracker->calculate($randomId); @@ -131,6 +370,8 @@ public static function run(callable $callback, array $params = [], string $unit * * @param string $id The identifier for the timer. * @return Result|null The Result instance or null if the timer does not exist. + * + * @throws UnmatchedPauseWithoutResume */ public function calculate(string $id): ?Result { @@ -138,9 +379,26 @@ public function calculate(string $id): ?Result return null; } - return new Result($this->unit, $this->end[$id] - $this->start[$id], 's'); + $calculate = $this->end[$id] - $this->start[$id]; + + $pausedTime = 0; + + if (!empty($this->pause[$id]) && !empty($this->resume[$id])) { + foreach ($this->pause[$id] as $index => $pauseTime) { + if (isset($this->resume[$id][$index])) { + $pausedTime += $this->resume[$id][$index]['time'] - $pauseTime['time']; + } else { + throw new UnmatchedPauseWithoutResume($id); + } + } + } + + $calculate -= $pausedTime; + + return new Result($this->unit, $calculate, 's'); } + /** * Adds a custom unit definition based on seconds. * @@ -185,14 +443,14 @@ public function reset(?string $id = null): void public function status(string $id): string { if (!isset($this->start[$id])) { - return self::STATUS_NOT_STARTED; + return TimerStatus::NOT_STARTED->value; } if (!isset($this->end[$id])) { - return self::STATUS_IN_PROGRESS; + return TimerStatus::IN_PROGRESS->value; } - return self::STATUS_COMPLETED; + return TimerStatus::COMPLETED->value; } /** @@ -233,4 +491,30 @@ public function getUnit(): Unit { return $this->unit; } + + /** + * Inspects and retrieves detailed timing data for a specific timer. + * + * @param string $id The identifier of the timer to inspect. + * + * @return array{ + * start: float|null, + * end: float|null, + * paused: array, + * resumed: array, + * status: string, + * laps: array + * } A structured array containing all tracked data for the specified timer. + */ + public function inspect(string $id): array + { + return [ + 'start' => $this->start[$id] ?? null, + 'end' => $this->end[$id] ?? null, + 'paused' => $this->pause[$id] ?? [], + 'resumed' => $this->resume[$id] ?? [], + 'status' => $this->status($id), + 'laps' => $this->laps[$id] ?? [] + ]; + } } diff --git a/src/TimerStatus.php b/src/TimerStatus.php new file mode 100644 index 0000000..34cc7bb --- /dev/null +++ b/src/TimerStatus.php @@ -0,0 +1,10 @@ +start($id); usleep(50000); // 50ms delay - $tracker->end($id); + $tracker->stop($id); $result = $tracker->calculate($id); @@ -32,7 +37,7 @@ public function testEndWithInvalidId() $this->expectException(TimerNotStarted::class); $tracker = new TimeTracker(); - $tracker->end('invalid_timer'); + $tracker->stop('invalid_timer'); } public function testCalculateWithInvalidId() @@ -49,18 +54,18 @@ public function testStatus(): void $tracker = new TimeTracker(); $id = 'test_timer'; - $this->assertSame(TimeTracker::STATUS_NOT_STARTED, $tracker->status($id)); + $this->assertSame(TimerStatus::NOT_STARTED->value, $tracker->status($id)); $tracker->start($id); - $this->assertSame(TimeTracker::STATUS_IN_PROGRESS, $tracker->status($id)); + $this->assertSame(TimerStatus::IN_PROGRESS->value, $tracker->status($id)); - $tracker->end($id); - $this->assertSame(TimeTracker::STATUS_COMPLETED, $tracker->status($id)); + $tracker->stop($id); + $this->assertSame(TimerStatus::COMPLETED->value, $tracker->status($id)); } - public function testRun(): void + public function testWatch(): void { - $result = TimeTracker::run(function () { + $result = TimeTracker::watch(function () { usleep(50000); // 50ms delay }); @@ -71,11 +76,11 @@ public function testRun(): void $this->assertGreaterThan(0, $result['time']); } - public function testRunWithExceptionThrow() + public function testWatchWithExceptionThrow() { $this->expectException(RuntimeException::class); - $result = TimeTracker::run(function () { + $result = TimeTracker::watch(function () { usleep(50000); // 50ms delay invalidFunction(); }); @@ -123,11 +128,11 @@ public function testDurations(): void $tracker->start($id1); usleep(10000); // 10ms delay - $tracker->end($id1); + $tracker->stop($id1); $tracker->start($id2); usleep(20000); // 20ms delay - $tracker->end($id2); + $tracker->stop($id2); $durations = $tracker->durations(); @@ -143,8 +148,8 @@ public function testResetFunctionality(): void // Start multiple timers $timeTracker->start('timer1'); $timeTracker->start('timer2'); - $timeTracker->end('timer1'); - $timeTracker->end('timer2'); + $timeTracker->stop('timer1'); + $timeTracker->stop('timer2'); // Verify timers exist $this->assertTrue($timeTracker->exists('timer1')); @@ -250,4 +255,283 @@ public function testGetUnitDefinitions(): void $this->assertSame(['operator' => '*', 'value' => 1000], $definition); } + + public function testResultToString(): void + { + $result = new Result(new Unit(), 10, 's'); + $this->assertSame('10', "$result"); + } + + public function testStopWithoutSpecificId() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + + usleep(10000); // 10ms delay + + $timetracker->stop(); + + $this->assertTrue($timetracker->exists('timer1')); + } + + public function testStopThrowExceptionOnCallingWithoutActiveStartRecord() + { + $this->expectException(NoActiveTimerToStopException::class); + + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + $timetracker->stop(); + $timetracker->stop(); + } + + public function testStopThrowExceptionOnCallingWithoutStartRecord() + { + $this->expectException(TimerNotStarted::class); + + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + $timetracker->stop('timer2'); //non-existing timer + } + + public function testIsStarted() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + + $this->assertTrue($timetracker->isStarted('timer1')); + $this->assertFalse($timetracker->isStarted('timer2')); + } + + public function testIsStopped() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + usleep(10000); + $timetracker->stop('timer1'); + + $this->assertTrue($timetracker->isStopped('timer1')); + $this->assertFalse($timetracker->isStopped('timer2')); + } + + public function testGetEmptyActiveTimersWhenNoActiveRecord() + { + $timetracker = new TimeTracker(); + + $this->assertEmpty($timetracker->getActiveTimers()); + } + + public function testGetActiveTimers() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + $timetracker->start('timer2'); + + $this->assertSame(['timer1', 'timer2'], $timetracker->getActiveTimers()); + } + + public function testLap() + { + $timetracker = new TimeTracker(); + + $timetracker->start('timer1'); + + usleep(10000); // 10ms delay + $timetracker->lap('timer1', "After 10ms delay"); + + usleep(20000); // 20ms delay + $timetracker->lap('timer1', "After 20ms delay"); + + $timetracker->stop(); + + $laps = $timetracker->getLaps('timer1'); + + $this->assertNotEmpty($laps); + + $expected = [10, 20]; + foreach ($laps as $index => $lap) { + $this->assertSame("After {$expected[$index]}ms delay", $lap['description']); + } + } + + public function testLapThrowExceptionOnCallingWithoutTimerStarted() + { + $this->expectException(TimerNotStarted::class); + + $timetracker = new TimeTracker(); + + $timetracker->lap('timer1'); + } + + public function testPauseThrowsExceptionIfTimerNotStarted() + { + $tracker = new TimeTracker(); + + $this->expectException(TimerNotStarted::class); + + $tracker->pause('task'); + } + + public function testPauseWorksWhenTimerIsStarted() + { + $tracker = new TimeTracker(); + + $tracker->start('task'); + $tracker->pause('task', 'first pause'); + + $inspectData = $tracker->inspect('task'); + $pauseData = $inspectData['paused'][0]; + + $this->assertSame('first pause', $pauseData['description']); + $this->assertIsFloat($pauseData['time']); + } + + public function testPauseThrowsIfAlreadyPausedAndNotResumed() + { + $tracker = new TimeTracker(); + + $tracker->start('task'); + $tracker->pause('task'); + + $this->expectException(TimerAlreadyPaused::class); + + // second pause should fail + $tracker->pause('task'); + } + + public function testResumeThrowsExceptionIfTimerNotStarted() + { + $tracker = new TimeTracker(); + + $this->expectException(TimerNotStarted::class); + + $tracker->resume('task'); + } + + public function testResumeThrowsIfNoActivePause() + { + $tracker = new TimeTracker(); + $tracker->start('task'); + + $this->expectException(NoActivePausedTimerToResume::class); + + $tracker->resume('task'); + } + + public function testResumeWorksAfterPause() + { + $tracker = new TimeTracker(); + + $tracker->start('task'); + $tracker->pause('task', 'pause point'); + usleep(10_000); + $tracker->resume('task', 'resume point'); + + $inspectData = $tracker->inspect('task'); + $resumeData = $inspectData['resumed'][0]; + + $this->assertSame('resume point', $resumeData['description']); + $this->assertIsFloat($resumeData['time']); + } + + public function testMultiplePauseResumeCycles() + { + $tracker = new TimeTracker(); + + $tracker->start('task'); + + $tracker->pause('task'); + usleep(5000); + $tracker->resume('task'); + + $tracker->pause('task'); + usleep(5000); + $tracker->resume('task'); + + $tracker->stop(); + + $tracker->calculate('task'); + + $this->assertTrue(true); + } + + public function testCalculateThrowsExceptionForUnmatchedPause() + { + $tracker = new TimeTracker(); + + $id = 'task'; + + // Start timer and create pause/resume cycle with unmatched pause + $tracker->start($id); + usleep(5000); + + $tracker->pause($id, 'first pause'); + $tracker->resume($id, 'first resume'); + $tracker->pause($id, 'second pause'); + + $tracker->stop($id); + + $this->expectException(UnmatchedPauseWithoutResume::class); + $this->expectExceptionMessage("Unmatched pause without resume for timer with ID '{$id}'"); + + $tracker->calculate($id); + } + + public function testInspectMethod() + { + $tracker = new TimeTracker(); + $id = 'inspect_timer'; + + // Test inspect for non-started timer + $inspectData = $tracker->inspect($id); + $this->assertNull($inspectData['start']); + $this->assertNull($inspectData['end']); + $this->assertEmpty($inspectData['paused']); + $this->assertEmpty($inspectData['resumed']); + $this->assertSame(TimerStatus::NOT_STARTED->value, $inspectData['status']); + $this->assertEmpty($inspectData['laps']); + + // Start timer and add comprehensive data + $tracker->start($id); + usleep(5000); + + $tracker->lap($id, 'First checkpoint'); + usleep(3000); + + $tracker->pause($id, 'Taking a break'); + usleep(2000); + $tracker->resume($id, 'Back to work'); + usleep(4000); + + $tracker->lap($id, 'Second checkpoint'); + + $tracker->stop($id); + + $inspectData = $tracker->inspect($id); + + $this->assertIsFloat($inspectData['start']); + $this->assertIsFloat($inspectData['end']); + $this->assertGreaterThan($inspectData['start'], $inspectData['end']); + + $this->assertCount(1, $inspectData['paused']); + $this->assertSame('Taking a break', $inspectData['paused'][0]['description']); + $this->assertIsFloat($inspectData['paused'][0]['time']); + + $this->assertCount(1, $inspectData['resumed']); + $this->assertSame('Back to work', $inspectData['resumed'][0]['description']); + $this->assertIsFloat($inspectData['resumed'][0]['time']); + + $this->assertSame(TimerStatus::COMPLETED->value, $inspectData['status']); + + $this->assertCount(2, $inspectData['laps']); + $this->assertSame('First checkpoint', $inspectData['laps'][0]['description']); + $this->assertSame('Second checkpoint', $inspectData['laps'][1]['description']); + $this->assertIsFloat($inspectData['laps'][0]['time']); + $this->assertIsFloat($inspectData['laps'][1]['time']); + } }