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']);
+ }
}