diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbfb47d..b1144b57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## 4.0.1 under development -- no changes in this release. +- New #274: Add test helper functions for controlling HTML ID generation (@vjik) ## 4.0.0 March 18, 2026 diff --git a/README.md b/README.md index bcf73c33..29e34548 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,7 @@ Overall the helper has the following method groups. ## Documentation - [Internals](docs/internals.md) +- [Testing ID generation](docs/testing-id-generation.md) If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). diff --git a/docs/testing-id-generation.md b/docs/testing-id-generation.md new file mode 100644 index 00000000..9efa9f87 --- /dev/null +++ b/docs/testing-id-generation.md @@ -0,0 +1,114 @@ +# Testing ID Generation + +`Html::generateId()` produces IDs that include an `hrtime(true)` timestamp, making them unique across +requests but non-deterministic in tests. The file `src/test-functions.php` provides helpers that make +generated IDs predictable during testing. + +## Setup + +Include the file once before running tests: + +```php +require_once 'vendor/yiisoft/html/src/test-functions.php'; +``` + +## Functions + +### `\Yiisoft\Html\IdGenerator\disableSeed()` + +Disables the `hrtime()` seed: IDs become short and deterministic — `i1`, `i2`, `test1`, etc. +Call this in test setup to get predictable, hardcodable IDs. + +### `\Yiisoft\Html\IdGenerator\enableSeed()` + +Re-enables the `hrtime()` seed, reverting to the default behaviour where IDs include a timestamp. +Call this in test teardown to restore normal operation. + +### `\Yiisoft\Html\IdGenerator\reset()` + +Resets the counter to zero without changing the current seed mode. Useful when you need +a fresh counter mid-test while the seed is disabled. + +## Usage in PHPUnit + +### Bootstrap + +To load the file automatically for the entire test suite, add it to your bootstrap file. + +In `phpunit.xml`: + +```xml + +``` + +In `tests/bootstrap.php`: + +```php +// ... +require_once 'vendor/autoload.php'; +require_once 'vendor/yiisoft/html/src/test-functions.php'; +// ... +``` + +### Global disableSeed with per-test reset + +If all tests in the suite need deterministic IDs, call `disableSeed()` once in the bootstrap +and then call `reset()` at the start of each test that relies on specific ID values: + +In `tests/bootstrap.php`: + +```php +require_once 'vendor/yiisoft/html/src/test-functions.php'; + +\Yiisoft\Html\IdGenerator\disableSeed(); +``` + +In the test class: + +```php +use Yiisoft\Html\IdGenerator; + +final class MyTest extends TestCase +{ + public function testRendersLabel(): void + { + IdGenerator\reset(); + + $this->assertSame('', ...); + } +} +``` + +### `setUp()` and `tearDown()` + +Useful when only one test class (or a few) needs deterministic IDs while the rest of the suite +uses the default `hrtime()` behaviour — for example, when adding ID assertions to an existing +project without touching the global bootstrap. + +Call `disableSeed()` and `reset()` in `setUp()` so every test starts with deterministic IDs +from `i1`. Add `enableSeed()` in `tearDown()` to restore the default behaviour after the class +finishes: + +```php +use Yiisoft\Html\IdGenerator; + +final class MyTest extends TestCase +{ + protected function setUp(): void + { + IdGenerator\disableSeed(); + IdGenerator\reset(); + } + + protected function tearDown(): void + { + IdGenerator\enableSeed(); + } + + public function testRendersLabel(): void + { + // IDs are now i1, i2, … — safe to hardcode in assertions + $this->assertSame('', ...); + } +} +``` diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5966dbe5..ab785b6b 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - */ - private static array $generateIdCounter = []; - - /** - * Returns an autogenerated sequential ID. + * Generates a unique sequential ID composed of the given prefix, an `hrtime(true)` timestamp, + * and a counter that increments with each call sharing the same timestamp. + * + * @param string $prefix Prefix to prepend to the generated ID. * * @return string Autogenerated ID. * * @psalm-return non-empty-string + * + * @see https://github.com/yiisoft/html/blob/master/docs/testing-id-generation.md for controlling ID generation in + * tests. */ public static function generateId(string $prefix = 'i'): string { - $prefix .= hrtime(true); - if (isset(self::$generateIdCounter[$prefix])) { - $counter = ++self::$generateIdCounter[$prefix]; - } else { - $counter = 1; - self::$generateIdCounter = [$prefix => $counter]; - } - return $prefix . $counter; + return IdGenerator::generate($prefix); } /** diff --git a/src/IdGenerator.php b/src/IdGenerator.php new file mode 100644 index 00000000..abf38705 --- /dev/null +++ b/src/IdGenerator.php @@ -0,0 +1,39 @@ + + */ + public static array $counter = []; + + public static bool $useSeed = true; + + /** + * @psalm-return non-empty-string + */ + public static function generate(string $prefix): string + { + if (self::$useSeed) { + $prefix .= hrtime(true); + } + if (isset(self::$counter[$prefix])) { + $count = ++self::$counter[$prefix]; + } else { + $count = 1; + if (self::$useSeed) { + self::$counter = [$prefix => $count]; + } else { + self::$counter[$prefix] = $count; + } + } + return $prefix . $count; + } +} diff --git a/src/test-functions.php b/src/test-functions.php new file mode 100644 index 00000000..3b9f007d --- /dev/null +++ b/src/test-functions.php @@ -0,0 +1,32 @@ +assertMatchesRegularExpression('/^i\d+$/', Html::generateId()); + $this->assertMatchesRegularExpression('/^test\d+$/', Html::generateId('test')); + } + + public function testGenerateIdWithSeedDisabled(): void + { + IdGenerator\disableSeed(); + IdGenerator\reset(); + + $this->assertSame('i1', Html::generateId()); + $this->assertSame('i2', Html::generateId()); + $this->assertSame('i3', Html::generateId()); + } + + public function testGenerateIdWithSeedDisabledCustomPrefix(): void + { + IdGenerator\disableSeed(); + IdGenerator\reset(); + + $this->assertSame('test1', Html::generateId('test')); + $this->assertSame('test2', Html::generateId('test')); + } + + public function testGenerateIdWithSeedDisabledMixedPrefixesDoNotResetEachOther(): void + { + IdGenerator\disableSeed(); + IdGenerator\reset(); + + $this->assertSame('i1', Html::generateId()); + $this->assertSame('test1', Html::generateId('test')); + $this->assertSame('i2', Html::generateId()); + $this->assertSame('test2', Html::generateId('test')); + } + + public function testResetClearsCounterWithoutChangingSeedMode(): void + { + IdGenerator\disableSeed(); + IdGenerator\reset(); + + $this->assertSame('i1', Html::generateId()); + $this->assertSame('i2', Html::generateId()); + + IdGenerator\reset(); + + $this->assertSame('i1', Html::generateId()); + } + + public function testEnableSeedRestoresTimestampBehavior(): void + { + IdGenerator\disableSeed(); + IdGenerator\reset(); + $this->assertSame('i1', Html::generateId()); + + IdGenerator\enableSeed(); + + $this->assertMatchesRegularExpression('/^i\d{10,}/', Html::generateId()); + } +} diff --git a/tests/HtmlTest.php b/tests/HtmlTest.php index 188cbe8d..eaf0ffd2 100644 --- a/tests/HtmlTest.php +++ b/tests/HtmlTest.php @@ -15,35 +15,8 @@ use function array_key_exists; -require __DIR__ . '/mocks/hrtime.php'; - final class HtmlTest extends TestCase { - /** - * Use different values in different tests - * - * @var mixed - */ - public static $hrtimeResult; - - protected function setUp(): void - { - self::$hrtimeResult = null; - parent::setUp(); - } - - public function testGenerateId(): void - { - $this->assertMatchesRegularExpression('/i\d+/', Html::generateId()); - $this->assertMatchesRegularExpression('/test\d+/', Html::generateId('test')); - - self::$hrtimeResult = 123; - $this->assertSame('i1231', Html::generateId()); - $this->assertSame('i1232', Html::generateId()); - self::$hrtimeResult = 124; - $this->assertSame('i1241', Html::generateId()); - } - public static function dataEscapeJavaScriptStringValue(): array { return [ diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..0cfdb6ad --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,6 @@ +