diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fc8c836 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Normalise line endings on checkout +* text=auto eol=lf + +# Files and directories that must not ship in the Composer dist tarball. +# Keeps `composer require initphp/performance-meter` lean for end users. +/.github export-ignore +/docs export-ignore +/tests export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/.php-cs-fixer.cache export-ignore +/.phpunit.cache export-ignore +/.phpunit.result.cache export-ignore +/CHANGELOG.md export-ignore +/phpstan.neon.dist export-ignore +/phpunit.xml.dist export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..198ae8b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,136 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Tests / PHP ${{ matrix.php }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: ['8.1', '8.2', '8.3', '8.4'] + dependencies: [highest] + include: + - os: ubuntu-latest + php: '8.1' + dependencies: lowest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP ${{ matrix.php }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: pcov + tools: composer:v2 + ini-values: error_reporting=E_ALL, display_errors=On, zend.assertions=1 + + - name: Validate composer.json + run: composer validate --strict + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependencies }}-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-composer-${{ matrix.php }}-${{ matrix.dependencies }}- + ${{ runner.os }}-composer-${{ matrix.php }}- + + - name: Install dependencies (${{ matrix.dependencies }}) + run: | + if [ "${{ matrix.dependencies }}" = "lowest" ]; then + composer update --prefer-lowest --prefer-stable --no-interaction --no-progress --prefer-dist + else + composer install --no-interaction --no-progress --prefer-dist + fi + + - name: Run PHPUnit + run: vendor/bin/phpunit --testdox + + static-analysis: + name: Static Analysis (PHPStan) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + tools: composer:v2 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-static-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-composer-static- + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --memory-limit=512M --no-progress + + coding-standards: + name: Coding Standards (PHP-CS-Fixer) + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + coverage: none + tools: composer:v2 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-cs-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-composer-cs- + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Run PHP-CS-Fixer + env: + PHP_CS_FIXER_IGNORE_ENV: 1 + run: vendor/bin/php-cs-fixer fix --dry-run --diff --show-progress=none diff --git a/.gitignore b/.gitignore index 0abe7ad..775cec1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,9 @@ /.vscode/ /.vs/ /vendor/ -/composer.lock \ No newline at end of file +/composer.lock +/build/ +/.phpunit.cache/ +/.phpunit.result.cache +/.php-cs-fixer.cache +/.phpstan/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..f196f84 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,36 @@ +in([__DIR__ . '/src', __DIR__ . '/tests']) + ->name('*.php'); + +return (new PhpCsFixer\Config()) + ->setRiskyAllowed(true) + ->setRules([ + '@PSR12' => true, + '@PHP81Migration' => true, + 'declare_strict_types' => true, + 'array_syntax' => ['syntax' => 'short'], + 'no_unused_imports' => true, + 'ordered_imports' => [ + 'sort_algorithm' => 'alpha', + 'imports_order' => ['class', 'function', 'const'], + ], + 'single_quote' => true, + 'trailing_comma_in_multiline' => true, + 'native_function_invocation' => [ + 'include' => ['@compiler_optimized'], + 'scope' => 'namespaced', + 'strict' => true, + ], + 'binary_operator_spaces' => ['default' => 'single_space'], + 'concat_space' => ['spacing' => 'one'], + 'no_superfluous_phpdoc_tags' => ['allow_mixed' => true], + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_separation' => true, + 'phpdoc_trim' => true, + ]) + ->setFinder($finder) + ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache'); diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2de9ccc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to this project are 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). + +## [Unreleased] + +## [2.0.0] + +### Added + +- `PerformanceMeter::reset()` — clear every recorded checkpoint. +- `PerformanceMeter::has(string $name): bool` — check whether a checkpoint exists. +- `PerformanceMeter::peakMemoryUsage(int $decimal = 2, bool $realUsage = false): string` — report the process-wide peak memory. +- `PerformanceMeter::getPointers(): array` — snapshot copy of the registry. +- `memoryUsage()` gained an optional `bool $realUsage = false` parameter to switch between the emalloc-tracked and the system-allocated reading. +- `setPointer()` now captures both memory readings (`memory_get_usage(false)` and `memory_get_usage(true)`) so historical pointers can be queried either way. +- Dedicated exception type `InitPHP\PerformanceMeter\Exception\PointerNotFoundException` (extends `\InvalidArgumentException`). +- Comprehensive English documentation in `docs/`: `getting-started.md`, `api-reference.md`, `cookbook.md`. +- CI workflow (`.github/workflows/ci.yml`) covering PHP 8.1 → 8.4, PHPStan at `level: max`, and PHP-CS-Fixer enforcement. +- PHPUnit 10 test suite with 100% line, method, and class coverage. + +### Changed + +- **BREAKING:** Minimum PHP version raised from `>=7.4` to `^8.1`. +- **BREAKING:** Referencing a checkpoint that does not exist now throws `PointerNotFoundException` instead of silently falling back to "now". Affects both the `$startPoint` and a non-`null` `$endPoint` arguments of `elapsedTime()` and `memoryUsage()`. +- **BREAKING:** `memoryUsage()` no longer mis-reports freed memory ≥ 1 MB in KB. Negative deltas are now formatted in the correct unit with a leading `-`. +- **BREAKING:** Negative `$decimal` arguments now throw `\InvalidArgumentException`. +- **BREAKING:** The class is now `final`; `$pointers` is now `private`. +- All PHPDoc rewritten in English (org-wide convention). +- `composer.json` switched from `files` autoload to PSR-4; added `require-dev`, scripts (`test`, `phpstan`, `cs-check`, `cs-fix`, `qa`), keywords and support metadata. + +### Fixed + +- `memoryUsage()` returned a KB-formatted figure for deltas with `|delta| ≥ 1 MB` whenever the delta was negative (memory freed). The unit selection now uses `abs($delta)` and produces, e.g., `-3.00MB` instead of `-3072KB`. +- PHPDoc `@see PerformansMeter::setPointer()` typo on `mark()` corrected to a valid reference. +- `microtime()` parsing replaced with `microtime(true)`, removing an unnecessary string round-trip. + +## [1.0] + +Initial public release. + +[Unreleased]: https://github.com/InitPHP/PerformanceMeter/compare/v2.0.0...HEAD +[2.0.0]: https://github.com/InitPHP/PerformanceMeter/releases/tag/v2.0.0 +[1.0]: https://github.com/InitPHP/PerformanceMeter/releases/tag/v1.0 diff --git a/LICENSE b/LICENSE index 1725f44..5b361cf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 InitPHP +Copyright (c) 2022-2026 InitPHP Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index f4246d9..c8a1673 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ -# PerformanceMeter +# InitPHP PerformanceMeter -A zero-dependency, single-file, single-class PHP profiler for measuring elapsed time and memory usage between named checkpoints. +A zero-dependency, single-class PHP profiler for measuring elapsed time and memory usage between named checkpoints. -[![Latest Stable Version](http://poser.pugx.org/initphp/performancemeter/v)](https://packagist.org/packages/initphp/performancemeter) [![Total Downloads](http://poser.pugx.org/initphp/performancemeter/downloads)](https://packagist.org/packages/initphp/performancemeter) [![Latest Unstable Version](http://poser.pugx.org/initphp/performancemeter/v/unstable)](https://packagist.org/packages/initphp/performancemeter) [![License](http://poser.pugx.org/initphp/performancemeter/license)](https://packagist.org/packages/initphp/performancemeter) [![PHP Version Require](http://poser.pugx.org/initphp/performancemeter/require/php)](https://packagist.org/packages/initphp/performancemeter) +[![CI](https://github.com/InitPHP/PerformanceMeter/actions/workflows/ci.yml/badge.svg)](https://github.com/InitPHP/PerformanceMeter/actions/workflows/ci.yml) +[![Latest Stable Version](https://poser.pugx.org/initphp/performance-meter/v)](https://packagist.org/packages/initphp/performance-meter) +[![Total Downloads](https://poser.pugx.org/initphp/performance-meter/downloads)](https://packagist.org/packages/initphp/performance-meter) +[![License](https://poser.pugx.org/initphp/performance-meter/license)](https://packagist.org/packages/initphp/performance-meter) +[![PHP Version Require](https://poser.pugx.org/initphp/performance-meter/require/php)](https://packagist.org/packages/initphp/performance-meter) ## Positioning -This package is intentionally minimal — three static methods (`setPointer`, `elapsedTime`, `memoryUsage`) that work without any other dependency. It exists to fill a specific niche: +PerformanceMeter is intentionally minimal — a single `final` class with a handful of static methods that works without any other dependency. It exists to fill a specific niche: > Quick, single-file timing checks where pulling in a full profiling library would be overkill. @@ -22,8 +26,6 @@ It is **not** a replacement for full-featured profilers; it is the cheapest poss ### When NOT to use this -For anything that fits the description below, prefer a purpose-built tool: - | Need | Use instead | |---|---| | Application-level profiling with nested sections, periods, categories | [`symfony/stopwatch`](https://github.com/symfony/stopwatch) | @@ -36,20 +38,18 @@ If you are reaching for any of the above, this package is not the right tool — ## Requirements -- PHP 7.4 or higher -- No other dependencies +- PHP **8.1** or higher +- No runtime dependencies ## Installation -``` +```bash composer require initphp/performance-meter ``` -You can also include `src/PerformanceMeter.php` manually if you cannot use Composer — the package is a single file with no transitive dependencies. - -## Usage +You can also include `src/PerformanceMeter.php` (and `src/Exception/PointerNotFoundException.php`) manually if you cannot use Composer — the package has no transitive dependencies. -### Basic — elapsed time and memory between two named points +## Quick start ```php require_once 'vendor/autoload.php'; @@ -72,9 +72,9 @@ echo PerformanceMeter::memoryUsage('main', 'mainEnd', 2) . ' memory used' . PHP_ // 0.77KB memory used ``` -### Open-ended measurement — measure from a point up to "right now" +### Open-ended measurement -If you only pass a single pointer name, the second argument defaults to the current moment: +When you only pass a starting checkpoint, the second argument defaults to "now": ```php PerformanceMeter::setPointer('boot'); @@ -86,7 +86,7 @@ echo PerformanceMeter::elapsedTime('boot') . ' seconds since boot' . PHP_EOL; ### `mark()` alias -`mark($name)` is an alias for `setPointer($name)` for readers who prefer that vocabulary: +`mark($name)` is a one-to-one alias of `setPointer($name)` for readers who prefer stopwatch-style vocabulary: ```php PerformanceMeter::mark('before'); @@ -96,21 +96,72 @@ PerformanceMeter::mark('after'); echo PerformanceMeter::elapsedTime('before', 'after'); ``` -## API Reference +More usage patterns — peak memory, comparing two implementations, resetting between runs — live in [`docs/cookbook.md`](docs/cookbook.md). -| Method | Description | +## API at a glance + +| Method | Purpose | |---|---| -| `setPointer(string $name): void` | Record a checkpoint with the current `microtime()` and `memory_get_usage()`. Names are case-insensitive (stored lowercased). | +| `setPointer(string $name): void` | Record a checkpoint with the current time + memory. Case-insensitive. | | `mark(string $name): void` | Alias of `setPointer()`. | -| `elapsedTime(string $startPoint, ?string $endPoint = null, int $decimal = 4): float` | Seconds between two pointers. If `$endPoint` is `null`, "now" is used. | -| `memoryUsage(string $startPoint, ?string $endPoint = null, int $decimal = 2): string` | Memory delta between two pointers, formatted as `"x.xxKB"` or `"x.xxMB"`. If `$endPoint` is `null`, "now" is used. | +| `elapsedTime(string $start, ?string $end = null, int $decimal = 4): float` | Seconds between two checkpoints. `$end = null` ⇒ "now". | +| `memoryUsage(string $start, ?string $end = null, int $decimal = 2, bool $realUsage = false): string` | Memory delta, formatted as `"x.xxKB"` or `"x.xxMB"`. | +| `peakMemoryUsage(int $decimal = 2, bool $realUsage = false): string` | Peak memory used so far by the process. | +| `has(string $name): bool` | Whether a checkpoint with that name has been recorded. | +| `getPointers(): array` | Snapshot copy of every recorded checkpoint. | +| `reset(): void` | Clear all checkpoints. | + +`elapsedTime()` and `memoryUsage()` **throw** `InitPHP\PerformanceMeter\Exception\PointerNotFoundException` when `$start` (or a non-null `$end`) does not match a recorded checkpoint. + +Full reference with parameter notes, error conditions and runnable examples: [`docs/api-reference.md`](docs/api-reference.md). + +## Documentation + +- [`docs/getting-started.md`](docs/getting-started.md) — install, first measurement, conceptual model +- [`docs/api-reference.md`](docs/api-reference.md) — every public method, parameter by parameter +- [`docs/cookbook.md`](docs/cookbook.md) — real-world recipes (CLI benchmarks, cron timing, A/B comparisons, peak memory tracking, v1 → v2 migration) + +## Migrating from v1.x to v2.0 + +v2.0 is a clean break that fixes real bugs and tightens the API. Most callers only need to upgrade PHP. + +| Area | v1 behaviour | v2 behaviour | Action | +|---|---|---|---| +| PHP requirement | `>=7.4` | `^8.1` | Upgrade your runtime. | +| Missing `$startPoint` | Silently returned ~0 (`"now" – "now"`) | Throws `PointerNotFoundException` | Wrap in `try/catch` or call `PerformanceMeter::has()` first. | +| Missing non-null `$endPoint` | Silently fell back to "now" | Throws `PointerNotFoundException` | Same — fix the typo or check with `has()`. | +| `memoryUsage()` with a freed-memory delta ≥ 1 MB | Reported in `KB` (broken) | Reports correctly in `MB` with sign | No code change; output now matches expectations. | +| `decimal < 0` | Accepted, produced odd output | Throws `InvalidArgumentException` | Pass `decimal >= 0`. | +| Subclassing `PerformanceMeter` | Allowed (pointless — all-static) | Blocked (`final`) | Compose, do not inherit. | +| `protected static $pointers` | Visible to subclasses | `private` | Use `getPointers()` / `has()` / `reset()`. | +| New: `reset()`, `has()`, `peakMemoryUsage()`, `getPointers()` | — | Added | Opt-in. | + +A migration cookbook entry with side-by-side diffs lives in [`docs/cookbook.md`](docs/cookbook.md#v1--v2-migration). + +## Contributing + +This package follows the org-wide [InitPHP contribution guide](https://github.com/InitPHP/.github/blob/main/CONTRIBUTING.md) — PSR-12, `declare(strict_types=1);`, PHPStan at the configured level, PHPUnit-tested behaviour changes, Conventional Commits. + +Locally: + +```bash +composer install +composer test # PHPUnit +composer phpstan # static analysis +composer cs-check # coding standards (use cs-fix to apply) +composer qa # all of the above +``` + +CI runs on PHP 8.1 → 8.4 against both highest and lowest installable dependencies. + +## Security -Pointers are stored statically on the class, so all measurements share the same global registry within a process. This is intentional — it keeps the API as terse as possible for the use cases above. If you need isolated, instance-scoped measurement scopes, use `symfony/stopwatch`. +Please report security issues privately — see the org-wide [SECURITY.md](https://github.com/InitPHP/.github/blob/main/SECURITY.md). Do not open public issues for vulnerabilities. ## Credits -- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) <> +- [Muhammet ŞAFAK](https://www.muhammetsafak.com.tr) — <info@muhammetsafak.com.tr> ## License -Copyright © 2022 [MIT License](./LICENSE) +Released under the [MIT License](./LICENSE). Copyright © 2022-2026 InitPHP. diff --git a/composer.json b/composer.json index eebbfe8..da1249e 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,17 @@ { "name": "initphp/performance-meter", - "description": "InitPHP Performance Meter / PHP Basic Profiler", + "description": "Zero-dependency, single-file PHP profiler for measuring elapsed time and memory usage between named checkpoints.", "type": "library", "license": "MIT", - "autoload": { - "files": [ - "src/PerformanceMeter.php" - ] - }, + "keywords": [ + "profiler", + "benchmark", + "performance", + "memory", + "timing", + "stopwatch", + "initphp" + ], "authors": [ { "name": "Muhammet ŞAFAK", @@ -16,8 +20,53 @@ "homepage": "https://www.muhammetsafak.com.tr" } ], - "minimum-stability": "stable", + "support": { + "issues": "https://github.com/InitPHP/PerformanceMeter/issues", + "source": "https://github.com/InitPHP/PerformanceMeter", + "docs": "https://github.com/InitPHP/PerformanceMeter/tree/main/docs" + }, "require": { - "php": ">=7.4" + "php": "^8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5", + "phpstan/phpstan": "^1.11", + "friendsofphp/php-cs-fixer": "^3.50" + }, + "autoload": { + "psr-4": { + "InitPHP\\PerformanceMeter\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "InitPHP\\PerformanceMeter\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit", + "test-coverage": "phpunit --coverage-html build/coverage --coverage-text", + "phpstan": "phpstan analyse --memory-limit=512M", + "cs-check": "php-cs-fixer fix --dry-run --diff", + "cs-fix": "php-cs-fixer fix", + "qa": [ + "@cs-check", + "@phpstan", + "@test" + ] + }, + "scripts-descriptions": { + "test": "Run the PHPUnit test suite.", + "test-coverage": "Run tests and emit HTML + text coverage reports.", + "phpstan": "Run PHPStan static analysis at the configured level.", + "cs-check": "Verify coding standards without modifying files.", + "cs-fix": "Apply coding standard fixes in place.", + "qa": "Run the full QA pipeline (cs-check, phpstan, test)." + }, + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "sort-packages": true, + "allow-plugins": {} } } diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..c006c5d --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,300 @@ +# API Reference + +This document covers every public method on `InitPHP\PerformanceMeter\PerformanceMeter` and the one exception type it throws. Each entry lists the signature, parameters, return value, error conditions, and a minimal runnable example. + +All methods are **static**. The class is **`final`** and cannot be instantiated. + +## Index + +- [`setPointer()`](#setpointer) +- [`mark()`](#mark) +- [`elapsedTime()`](#elapsedtime) +- [`memoryUsage()`](#memoryusage) +- [`peakMemoryUsage()`](#peakmemoryusage) +- [`has()`](#has) +- [`getPointers()`](#getpointers) +- [`reset()`](#reset) +- [`PointerNotFoundException`](#pointernotfoundexception) + +--- + +## `setPointer()` + +```php +public static function setPointer(string $name): void +``` + +Record a checkpoint under the given name. Captures the current wall-clock time (`microtime(true)`) and both memory readings (`memory_get_usage(false)` and `memory_get_usage(true)`). + +### Parameters + +- `string $name` — Identifier for the checkpoint. Names are normalised to lower case before storage, so `'Foo'` and `'foo'` refer to the same checkpoint. Empty strings are valid. + +### Behaviour + +- Calling `setPointer()` again with a name that already exists **overwrites** the previous reading. +- The checkpoint persists for the lifetime of the PHP process unless cleared with [`reset()`](#reset). + +### Example + +```php +PerformanceMeter::setPointer('warm-cache'); +warm_cache(); +PerformanceMeter::setPointer('cache-warm'); +``` + +--- + +## `mark()` + +```php +public static function mark(string $name): void +``` + +One-to-one alias of [`setPointer()`](#setpointer). Exposed for callers who prefer stopwatch-style vocabulary. Identical behaviour, identical state — calling `setPointer('x')` and then `mark('x')` overwrites the same entry. + +### Example + +```php +PerformanceMeter::mark('request:start'); +$response = $kernel->handle($request); +PerformanceMeter::mark('request:end'); +``` + +--- + +## `elapsedTime()` + +```php +public static function elapsedTime( + string $startPoint, + ?string $endPoint = null, + int $decimal = 4, +): float +``` + +Measure the wall-clock seconds between two checkpoints. + +### Parameters + +- `string $startPoint` — Name of the starting checkpoint. **Must already exist** or `PointerNotFoundException` is thrown. +- `?string $endPoint` — Name of the ending checkpoint. When `null` (default), the current moment is captured and used as the end of the interval. When a non-null name is given, it **must already exist**. +- `int $decimal` — Number of fractional digits to round to. Must be `>= 0`. + +### Returns + +`float` — Elapsed time in seconds, rounded to the requested precision. Always uses the absolute difference, so it is non-negative as long as `$endPoint` was recorded after `$startPoint`. + +### Throws + +- `PointerNotFoundException` — `$startPoint` is unknown, or a non-null `$endPoint` is unknown. +- `InvalidArgumentException` — `$decimal < 0`. + +### Examples + +Two explicit checkpoints: + +```php +PerformanceMeter::setPointer('a'); +do_work(); +PerformanceMeter::setPointer('b'); + +echo PerformanceMeter::elapsedTime('a', 'b', 6), "s\n"; +``` + +Open-ended (since boot): + +```php +PerformanceMeter::setPointer('boot'); +// ... whole request lifecycle ... +echo PerformanceMeter::elapsedTime('boot'), "s elapsed since boot\n"; +``` + +--- + +## `memoryUsage()` + +```php +public static function memoryUsage( + string $startPoint, + ?string $endPoint = null, + int $decimal = 2, + bool $realUsage = false, +): string +``` + +Measure the memory delta between two checkpoints and format it as a human-readable string. + +### Parameters + +- `string $startPoint` — See [`elapsedTime()`](#elapsedtime). +- `?string $endPoint` — See [`elapsedTime()`](#elapsedtime). +- `int $decimal` — Fractional digits in the formatted output. Must be `>= 0`. +- `bool $realUsage` — When `true`, use the system-allocated memory (`memory_get_usage(true)`) instead of the emalloc-tracked figure. Default is `false`. + +### Returns + +`string` — One of: + +- `"KB"` when `|delta| < 1 MB` +- `"MB"` when `|delta| >= 1 MB` + +The sign is preserved, so a freed-memory delta will start with `-` (for example `"-3.00MB"`). + +### Throws + +- `PointerNotFoundException` — `$startPoint` is unknown, or a non-null `$endPoint` is unknown. +- `InvalidArgumentException` — `$decimal < 0`. + +### Examples + +```php +PerformanceMeter::setPointer('m1'); +$payload = str_repeat('x', 2_000_000); +PerformanceMeter::setPointer('m2'); + +echo PerformanceMeter::memoryUsage('m1', 'm2'), "\n"; // "1.91MB" +echo PerformanceMeter::memoryUsage('m1', 'm2', 4, true), "\n"; // real-usage variant +``` + +Freed memory: + +```php +$payload = str_repeat('x', 3 * 1024 * 1024); +PerformanceMeter::setPointer('before'); +unset($payload); +PerformanceMeter::setPointer('after'); + +echo PerformanceMeter::memoryUsage('before', 'after'); // "-3.00MB" +``` + +--- + +## `peakMemoryUsage()` + +```php +public static function peakMemoryUsage(int $decimal = 2, bool $realUsage = false): string +``` + +Report the peak memory used so far by the PHP process, formatted with the same KB/MB suffix rules as [`memoryUsage()`](#memoryusage). Reflects PHP's `memory_get_peak_usage()`. + +### Parameters + +- `int $decimal` — Fractional digits in the output. Must be `>= 0`. +- `bool $realUsage` — When `true`, returns the system-allocated peak. + +### Returns + +`string` — `"KB"` or `"MB"`. Always non-negative. + +### Throws + +- `InvalidArgumentException` — `$decimal < 0`. + +### Example + +```php +import_large_dataset(); +echo PerformanceMeter::peakMemoryUsage(), "\n"; // "8.50MB" +echo PerformanceMeter::peakMemoryUsage(2, true), "\n"; // real-usage peak +``` + +--- + +## `has()` + +```php +public static function has(string $name): bool +``` + +Return whether a checkpoint with the given name has been recorded. Case-insensitive lookup, matching the lowercase normalisation used by `setPointer()`. + +### Example + +```php +if (!PerformanceMeter::has('request:start')) { + PerformanceMeter::setPointer('request:start'); +} +``` + +--- + +## `getPointers()` + +```php +public static function getPointers(): array +``` + +Return a snapshot copy of the entire checkpoint registry. + +### Returns + +`array` — Keyed by the lowercased checkpoint name. The returned array is a copy (PHP's copy-on-write semantics apply); mutating it has no effect on the internal registry. + +### Example + +```php +PerformanceMeter::setPointer('alpha'); +PerformanceMeter::setPointer('beta'); + +foreach (PerformanceMeter::getPointers() as $name => $snapshot) { + printf("%s @ %.6fs / %d bytes\n", $name, $snapshot['time'], $snapshot['memory']); +} +``` + +--- + +## `reset()` + +```php +public static function reset(): void +``` + +Clear every recorded checkpoint. Safe to call when the registry is already empty (idempotent). + +### When to use it + +- Between independent benchmark runs in a long-lived script +- In test suites, to prevent state from one test bleeding into another (`setUp()` is the natural place) +- In long-running workers, to bound the registry's memory footprint + +### Example + +```php +foreach ($scenarios as $name => $scenario) { + PerformanceMeter::reset(); + PerformanceMeter::setPointer('start'); + $scenario(); + printf("%s: %ss\n", $name, PerformanceMeter::elapsedTime('start')); +} +``` + +--- + +## `PointerNotFoundException` + +```php +namespace InitPHP\PerformanceMeter\Exception; + +final class PointerNotFoundException extends \InvalidArgumentException +{ + public static function forName(string $name): self; +} +``` + +Thrown by `elapsedTime()` and `memoryUsage()` when a referenced checkpoint name has not been recorded. The exception message includes the offending name so the source of the typo is obvious. + +Because it extends `\InvalidArgumentException`, broad `catch (\InvalidArgumentException $e)` blocks will also catch it. Catch the specific class when you need to distinguish "you asked for a checkpoint that does not exist" from other argument-validation errors. + +### Example + +```php +use InitPHP\PerformanceMeter\Exception\PointerNotFoundException; + +try { + PerformanceMeter::elapsedTime('boot'); +} catch (PointerNotFoundException $e) { + // first call in this process — record the boot checkpoint and move on + PerformanceMeter::setPointer('boot'); +} +``` diff --git a/docs/cookbook.md b/docs/cookbook.md new file mode 100644 index 0000000..266889c --- /dev/null +++ b/docs/cookbook.md @@ -0,0 +1,292 @@ +# Cookbook + +Practical recipes for using `PerformanceMeter` in real codebases. Each recipe is self-contained and copy-pasteable. + +## Index + +- [CLI benchmark script with summary at the end](#cli-benchmark-script-with-summary-at-the-end) +- [Cron job timing log](#cron-job-timing-log) +- [A/B comparison of two implementations](#ab-comparison-of-two-implementations) +- [Peak memory tracking](#peak-memory-tracking) +- [Nested measurements inside a loop](#nested-measurements-inside-a-loop) +- [Conditional measurement (probe-or-skip)](#conditional-measurement-probe-or-skip) +- [v1 → v2 migration](#v1--v2-migration) + +--- + +## CLI benchmark script with summary at the end + +A small `bench.php` you can drop next to a workload and run with `php bench.php`. + +```php + $i, 'hash' => hash('sha256', (string) $i)]; +} +// ------------------------------------------------------------------------ + +PerformanceMeter::setPointer('done'); + +printf( + "rows: %d\nelapsed: %.4fs\nmemory: %s\npeak: %s\n", + count($rows), + PerformanceMeter::elapsedTime('boot', 'done'), + PerformanceMeter::memoryUsage('boot', 'done'), + PerformanceMeter::peakMemoryUsage(), +); +``` + +Sample output: + +``` +rows: 100000 +elapsed: 0.1843s +memory: 19.84MB +peak: 21.12MB +``` + +--- + +## Cron job timing log + +Useful when you want a single line per run, appended to a log file, telling you how long the job took and how much memory it touched. Pair with `logrotate` and you have a low-fi performance trend. + +```php + static fn () => array_sum($payload), + 'foreach' => static function () use ($payload) { + $total = 0; + foreach ($payload as $n) { + $total += $n; + } + return $total; + }, +]; + +foreach ($candidates as $name => $fn) { + PerformanceMeter::reset(); + PerformanceMeter::setPointer('start'); + $fn(); + PerformanceMeter::setPointer('end'); + + printf( + "%-10s elapsed=%ss memory=%s\n", + $name, + PerformanceMeter::elapsedTime('start', 'end', 6), + PerformanceMeter::memoryUsage('start', 'end'), + ); +} +``` + +> **Microbenchmark caveat.** A single-shot measurement is noisy. Wrap each scenario in a `for ($i = 0; $i < $iterations; $i++)` loop and divide if you need stable numbers. For statistical rigour (warm-up, outliers, confidence intervals), reach for [`phpbench/phpbench`](https://github.com/phpbench/phpbench) instead. + +--- + +## Peak memory tracking + +`peakMemoryUsage()` does not need start/end checkpoints — it asks PHP for the watermark since the process began (or since the last `memory_reset_peak_usage()` call, on PHP 8.2+). + +```php + $batch) { + PerformanceMeter::setPointer("batch:$index:start"); + process($batch); + PerformanceMeter::setPointer("batch:$index:end"); +} + +foreach (array_keys($batches) as $index) { + printf( + "batch %d: %ss\n", + $index, + PerformanceMeter::elapsedTime("batch:$index:start", "batch:$index:end"), + ); +} +``` + +If you do not need per-iteration breakdowns and just want a running total, collect the deltas as you go and discard the checkpoints: + +```php +$total = 0.0; +foreach ($batches as $batch) { + PerformanceMeter::setPointer('it-start'); + process($batch); + PerformanceMeter::setPointer('it-end'); + + $total += PerformanceMeter::elapsedTime('it-start', 'it-end', 6); +} +PerformanceMeter::reset(); // optional, to free the two slots +printf("Total processing time: %.6fs\n", $total); +``` + +--- + +## Conditional measurement (probe-or-skip) + +Sometimes the first call site does not know whether an earlier code path already recorded a checkpoint. Guard with `has()` instead of catching the exception: + +```php + + + + + tests + + + + + src + + + + + + + + diff --git a/src/Exception/PointerNotFoundException.php b/src/Exception/PointerNotFoundException.php new file mode 100644 index 0000000..42ddb56 --- /dev/null +++ b/src/Exception/PointerNotFoundException.php @@ -0,0 +1,22 @@ + + * @copyright Copyright © 2022 InitPHP + * @license https://opensource.org/licenses/MIT MIT License * - * @author Muhammet ŞAFAK - * @copyright Copyright © 2022 InitPHP - * @license http://initphp.github.io/license.txt MIT - * @version 1.0 - * @link https://www.muhammetsafak.com.tr + * @link https://github.com/InitPHP/PerformanceMeter */ -declare(strict_types=1); - namespace InitPHP\PerformanceMeter; -use function explode; -use function microtime; +use InitPHP\PerformanceMeter\Exception\PointerNotFoundException; +use InvalidArgumentException; + +use function abs; +use function memory_get_peak_usage; use function memory_get_usage; +use function microtime; use function round; use function strtolower; -class PerformanceMeter +/** + * Zero-dependency static profiler for measuring elapsed time and memory + * usage between named checkpoints. + * + * Checkpoints are stored in a single class-level registry, so all + * measurements share the same global scope within a process. This is + * intentional — it keeps the API as small as possible for one-off + * benchmarking scripts, CLI tools, and reproduction snippets. + * + * If you need isolated scopes, nested sections, or production-grade + * profiling, prefer `symfony/stopwatch` or a real profiler such as + * Xdebug, Blackfire, or SPX. + * + * @api + */ +final class PerformanceMeter { + /** + * Number of bytes in a kibibyte (KB), used by the formatter. + */ + private const BYTES_PER_KB = 1024; + + /** + * Number of bytes in a mebibyte (MB), used by the formatter. + */ + private const BYTES_PER_MB = 1024 * 1024; - protected static array $pointers = []; + /** + * Pointer registry. Each entry is keyed by the lowercased pointer + * name and holds the timestamp + both memory readings captured at + * setPointer() time. + * + * @var array + */ + private static array $pointers = []; + + /** + * The class is purely static and must not be instantiated. + * + * @codeCoverageIgnore + */ + private function __construct() + { + } /** - * Ölçüm noktası oluşturur. + * Record a checkpoint under the given name. * - * @param string $name + * Captures the current wall-clock time (`microtime(true)`) and both + * memory readings (`memory_get_usage(false)` and `memory_get_usage(true)`). + * Names are normalised to lower case, so `"Foo"` and `"foo"` refer to + * the same checkpoint. Calling this method again with an existing name + * overwrites the previous reading. */ public static function setPointer(string $name): void { - $mtime = explode(' ', microtime()); - self::$pointers[strtolower($name)] = [ - 'time' => $mtime[1] + $mtime[0], - 'memory' => memory_get_usage(), - ]; + self::$pointers[strtolower($name)] = self::captureNow(); } /** - * @see PerformansMeter::setPointer() - * @param string $name - * @return void + * Alias of {@see self::setPointer()} for readers who prefer + * stopwatch-style vocabulary. */ public static function mark(string $name): void { @@ -51,55 +93,179 @@ public static function mark(string $name): void } /** - * İki ölçüm noktası arasındaki zamanı ölçer. + * Measure the elapsed wall-clock time between two checkpoints, in seconds. + * + * If `$endPoint` is `null`, the current moment is used as the end of + * the interval — useful for "since boot" style measurements without + * having to mark an explicit end. * - * @param string $startPoint

Ölçüm için kullanılacak birinci noktanın adı.

- * @param string|null $endPoint

Ölçüm için kullanılacak ikinci noktanın adı. null ise kullanıldığı yer kabul edilir.

- * @param int $decimal

Sonucun kayar noktalı sayı kısmında noktadan sonra kaç hane gösterileceğini belirtir.

- * @return float + * @param string $startPoint Name of the starting checkpoint. Must already exist. + * @param string|null $endPoint Name of the ending checkpoint, or `null` for "now". + * @param int $decimal Number of fractional digits to round to. Must be ≥ 0. + * + * @throws PointerNotFoundException If `$startPoint` or a non-null `$endPoint` is unknown. + * @throws InvalidArgumentException If `$decimal` is negative. */ public static function elapsedTime(string $startPoint, ?string $endPoint = null, int $decimal = 4): float { - $start = self::getPointer($startPoint); - $end = self::getPointer($endPoint); - return round(($end['time'] - $start['time']), $decimal); + self::assertDecimal($decimal); + + $start = self::requirePointer($startPoint); + $end = self::resolveEndPoint($endPoint); + + return round($end['time'] - $start['time'], $decimal); + } + + /** + * Measure the memory-usage delta between two checkpoints, formatted + * as a human-readable string ending in "KB" or "MB". + * + * The delta may be negative when memory has been freed between the + * two checkpoints; the formatted output preserves the sign + * (e.g. `-1.50MB`). + * + * @param string $startPoint Name of the starting checkpoint. Must already exist. + * @param string|null $endPoint Name of the ending checkpoint, or `null` for "now". + * @param int $decimal Number of fractional digits to round to. Must be ≥ 0. + * @param bool $realUsage When `true`, use the system-allocated memory + * (`memory_get_usage(true)`) instead of the + * emalloc-tracked figure. + * + * @throws PointerNotFoundException If `$startPoint` or a non-null `$endPoint` is unknown. + * @throws InvalidArgumentException If `$decimal` is negative. + */ + public static function memoryUsage( + string $startPoint, + ?string $endPoint = null, + int $decimal = 2, + bool $realUsage = false, + ): string { + self::assertDecimal($decimal); + + $start = self::requirePointer($startPoint); + $end = self::resolveEndPoint($endPoint); + + $key = $realUsage ? 'memoryReal' : 'memory'; + $delta = $end[$key] - $start[$key]; + + return self::formatBytes($delta, $decimal); + } + + /** + * Report the peak memory usage of the current process, formatted as + * a human-readable string. Reflects PHP's `memory_get_peak_usage()`. + * + * @param int $decimal Number of fractional digits to round to. Must be ≥ 0. + * @param bool $realUsage When `true`, use the system-allocated peak. + * + * @throws InvalidArgumentException If `$decimal` is negative. + */ + public static function peakMemoryUsage(int $decimal = 2, bool $realUsage = false): string + { + self::assertDecimal($decimal); + + return self::formatBytes(memory_get_peak_usage($realUsage), $decimal); + } + + /** + * Determine whether a checkpoint with the given name has been recorded. + */ + public static function has(string $name): bool + { + return isset(self::$pointers[strtolower($name)]); + } + + /** + * Return a snapshot of all recorded checkpoints, keyed by name. + * + * The returned array is a copy of the internal registry; mutating + * it will not affect subsequent measurements. + * + * @return array + */ + public static function getPointers(): array + { + return self::$pointers; + } + + /** + * Clear every recorded checkpoint, returning the registry to its + * initial empty state. Useful in long-running processes and in + * test suites where measurements must not bleed across cases. + */ + public static function reset(): void + { + self::$pointers = []; + } + + /** + * Return the stored snapshot for `$name`, throwing if absent. + * + * @return array{time: float, memory: int, memoryReal: int} + * + * @throws PointerNotFoundException + */ + private static function requirePointer(string $name): array + { + $key = strtolower($name); + + if (!isset(self::$pointers[$key])) { + throw PointerNotFoundException::forName($name); + } + + return self::$pointers[$key]; } /** - * İki ölçüm noktası arasındaki bellek kullanımını ölçer. + * Resolve the end-of-interval snapshot: either a stored pointer or + * the current moment when `$name` is `null`. + * + * @return array{time: float, memory: int, memoryReal: int} * - * @param string $startPoint

Ölçüm için kullanılacak birinci noktanın adı.

- * @param string|null $endPoint

Ölçüm için kullanılacak ikinci noktanın adı. null ise kullanıldığı yer kabul edilir.

- * @param int $decimal

Sonucun kayar noktalı sayı kısmında noktadan sonra kaç hane gösterileceğini belirtir.

- * @return string + * @throws PointerNotFoundException When a non-null name does not exist. */ - public static function memoryUsage(string $startPoint, ?string $endPoint = null, int $decimal = 2): string + private static function resolveEndPoint(?string $name): array { - $start = self::getPointer($startPoint); - $end = self::getPointer($endPoint); - $memoryUse = $end['memory'] - $start['memory']; - if($memoryUse < 1048576){ - return round(($memoryUse / 1024), $decimal) . 'KB'; + if ($name === null) { + return self::captureNow(); } - return round(($memoryUse / 1048576), $decimal) . 'MB'; + + return self::requirePointer($name); } /** - * @param string|null $name - * @return array + * Capture the current wall-clock time and memory usage. + * + * @return array{time: float, memory: int, memoryReal: int} */ - protected static function getPointer(?string $name = null): array + private static function captureNow(): array { - if($name !== null){ - $name = strtolower($name); + return [ + 'time' => microtime(true), + 'memory' => memory_get_usage(false), + 'memoryReal' => memory_get_usage(true), + ]; + } + + /** + * Format a byte count as "x.xxKB" or "x.xxMB", preserving sign. + */ + private static function formatBytes(int $bytes, int $decimal): string + { + if (abs($bytes) < self::BYTES_PER_MB) { + return round($bytes / self::BYTES_PER_KB, $decimal) . 'KB'; } - if($name === null || !isset(self::$pointers[$name])){ - $mtime = explode(' ', microtime()); - return [ - 'time' => $mtime[1] + $mtime[0], - 'memory' => memory_get_usage() - ]; + + return round($bytes / self::BYTES_PER_MB, $decimal) . 'MB'; + } + + /** + * @throws InvalidArgumentException + */ + private static function assertDecimal(int $decimal): void + { + if ($decimal < 0) { + throw new InvalidArgumentException('The $decimal argument must be greater than or equal to 0.'); } - return self::$pointers[$name]; } } diff --git a/tests/PerformanceMeterTest.php b/tests/PerformanceMeterTest.php new file mode 100644 index 0000000..d5ac139 --- /dev/null +++ b/tests/PerformanceMeterTest.php @@ -0,0 +1,338 @@ +expectException(PointerNotFoundException::class); + $this->expectExceptionMessage('"missing"'); + + PerformanceMeter::elapsedTime('missing'); + } + + public function testElapsedTimeWithMissingEndPointThrows(): void + { + PerformanceMeter::setPointer('present'); + + $this->expectException(PointerNotFoundException::class); + $this->expectExceptionMessage('"typo"'); + + PerformanceMeter::elapsedTime('present', 'typo'); + } + + public function testElapsedTimeRespectsDecimalArgument(): void + { + PerformanceMeter::setPointer('a'); + usleep(1_500); + PerformanceMeter::setPointer('b'); + + $rounded = PerformanceMeter::elapsedTime('a', 'b', 0); + $unrounded = PerformanceMeter::elapsedTime('a', 'b', 6); + + self::assertSame((float) (int) $rounded, $rounded, 'decimal=0 must return an integral float.'); + self::assertGreaterThanOrEqual(0.001, $unrounded); + } + + public function testElapsedTimeRejectsNegativeDecimal(): void + { + PerformanceMeter::setPointer('a'); + PerformanceMeter::setPointer('b'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('$decimal'); + + PerformanceMeter::elapsedTime('a', 'b', -1); + } + + public function testMemoryUsageReturnsKBSuffixForSmallDelta(): void + { + PerformanceMeter::setPointer('m1'); + $payload = str_repeat('x', 4_096); // ~4 KB + PerformanceMeter::setPointer('m2'); + + $output = PerformanceMeter::memoryUsage('m1', 'm2'); + + self::assertStringEndsWith('KB', $output); + self::assertNotSame($payload, ''); // keep reference + } + + public function testMemoryUsageReturnsMBSuffixForLargeDelta(): void + { + PerformanceMeter::setPointer('m1'); + $payload = str_repeat('x', 2 * 1024 * 1024); // 2 MB + PerformanceMeter::setPointer('m2'); + + $output = PerformanceMeter::memoryUsage('m1', 'm2'); + + self::assertStringEndsWith('MB', $output); + self::assertNotSame($payload, ''); + } + + /** + * Regression test for the negative-delta bug in v1: when memory was + * freed between two checkpoints (delta > 1 MB in absolute value), the + * formatter would incorrectly report the figure in KB. + */ + public function testMemoryUsageWithNegativeMultiMegabyteDeltaUsesMBSuffix(): void + { + $payload = str_repeat('x', 3 * 1024 * 1024); // 3 MB + PerformanceMeter::setPointer('before-free'); + unset($payload); + PerformanceMeter::setPointer('after-free'); + + $output = PerformanceMeter::memoryUsage('before-free', 'after-free'); + + self::assertStringEndsWith('MB', $output); + self::assertStringStartsWith('-', $output, 'Freed memory must format as a negative delta.'); + } + + public function testMemoryUsageWithNullEndPointMeasuresUpToNow(): void + { + PerformanceMeter::setPointer('mem-start'); + $payload = str_repeat('x', 1_024); + + $output = PerformanceMeter::memoryUsage('mem-start'); + + self::assertMatchesRegularExpression('/^-?\d+(\.\d+)?(KB|MB)$/', $output); + self::assertNotSame($payload, ''); + } + + public function testMemoryUsageWithMissingStartPointThrows(): void + { + $this->expectException(PointerNotFoundException::class); + + PerformanceMeter::memoryUsage('nope'); + } + + public function testMemoryUsageWithMissingEndPointThrows(): void + { + PerformanceMeter::setPointer('here'); + + $this->expectException(PointerNotFoundException::class); + + PerformanceMeter::memoryUsage('here', 'nowhere'); + } + + public function testMemoryUsageRealUsageFlagIsAccepted(): void + { + PerformanceMeter::setPointer('r1'); + PerformanceMeter::setPointer('r2'); + + $output = PerformanceMeter::memoryUsage('r1', 'r2', 2, true); + + self::assertMatchesRegularExpression('/^-?\d+(\.\d+)?(KB|MB)$/', $output); + } + + public function testMemoryUsageRejectsNegativeDecimal(): void + { + PerformanceMeter::setPointer('a'); + PerformanceMeter::setPointer('b'); + + $this->expectException(InvalidArgumentException::class); + + PerformanceMeter::memoryUsage('a', 'b', -2); + } + + public function testPeakMemoryUsageFormatsAsKBorMB(): void + { + $output = PerformanceMeter::peakMemoryUsage(); + + self::assertMatchesRegularExpression('/^\d+(\.\d+)?(KB|MB)$/', $output); + } + + public function testPeakMemoryUsageRealUsageFlagIsAccepted(): void + { + $output = PerformanceMeter::peakMemoryUsage(2, true); + + self::assertMatchesRegularExpression('/^\d+(\.\d+)?(KB|MB)$/', $output); + } + + public function testPeakMemoryUsageRejectsNegativeDecimal(): void + { + $this->expectException(InvalidArgumentException::class); + + PerformanceMeter::peakMemoryUsage(-1); + } + + public function testHasReturnsFalseForUnknownPointer(): void + { + self::assertFalse(PerformanceMeter::has('ghost')); + } + + public function testHasIsCaseInsensitive(): void + { + PerformanceMeter::setPointer('Section-1'); + + self::assertTrue(PerformanceMeter::has('section-1')); + self::assertTrue(PerformanceMeter::has('SECTION-1')); + } + + public function testGetPointersReturnsSnapshotKeyedByLowercaseName(): void + { + PerformanceMeter::setPointer('Foo'); + PerformanceMeter::setPointer('BAR'); + + $pointers = PerformanceMeter::getPointers(); + + self::assertArrayHasKey('foo', $pointers); + self::assertArrayHasKey('bar', $pointers); + self::assertArrayHasKey('time', $pointers['foo']); + self::assertArrayHasKey('memory', $pointers['foo']); + self::assertArrayHasKey('memoryReal', $pointers['foo']); + } + + public function testGetPointersReturnsCopyThatDoesNotMutateInternalState(): void + { + PerformanceMeter::setPointer('immutable'); + + $snapshot = PerformanceMeter::getPointers(); + $snapshot['immutable']['time'] = 0.0; + unset($snapshot['immutable']); + + self::assertTrue(PerformanceMeter::has('immutable')); + self::assertNotSame(0.0, PerformanceMeter::getPointers()['immutable']['time']); + } + + public function testResetClearsAllPointers(): void + { + PerformanceMeter::setPointer('one'); + PerformanceMeter::setPointer('two'); + + PerformanceMeter::reset(); + + self::assertSame([], PerformanceMeter::getPointers()); + self::assertFalse(PerformanceMeter::has('one')); + self::assertFalse(PerformanceMeter::has('two')); + } + + public function testResetIsIdempotent(): void + { + PerformanceMeter::reset(); + PerformanceMeter::reset(); + + self::assertSame([], PerformanceMeter::getPointers()); + } + + public function testElapsedTimeBetweenIdenticalPointerNameIsZero(): void + { + PerformanceMeter::setPointer('same'); + + self::assertSame(0.0, PerformanceMeter::elapsedTime('same', 'same', 6)); + } + + public function testEmptyStringIsAValidPointerName(): void + { + PerformanceMeter::setPointer(''); + + self::assertTrue(PerformanceMeter::has('')); + self::assertIsFloat(PerformanceMeter::elapsedTime('')); + } + + /** + * @param non-empty-string $expectedSuffix + */ + #[DataProvider('byteFormattingProvider')] + public function testFormatterBoundaryBehaviour(int $payloadBytes, string $expectedSuffix): void + { + PerformanceMeter::setPointer('p1'); + $payload = str_repeat('x', $payloadBytes); + PerformanceMeter::setPointer('p2'); + + $output = PerformanceMeter::memoryUsage('p1', 'p2'); + + self::assertStringEndsWith($expectedSuffix, $output); + self::assertNotSame($payload, ''); + } + + /** + * @return iterable + */ + public static function byteFormattingProvider(): iterable + { + yield 'sub-MB stays in KB' => [512 * 1024, 'KB']; // 512 KB + yield 'multi-MB switches to MB' => [4 * 1024 * 1024, 'MB']; // 4 MB + } + + public function testPointerNotFoundExceptionIsInvalidArgumentException(): void + { + $exception = PointerNotFoundException::forName('x'); + + self::assertInstanceOf(InvalidArgumentException::class, $exception); + self::assertStringContainsString('"x"', $exception->getMessage()); + } +}