From d03b5360f21139cc7f33c5c5bd5de18787383df8 Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Thu, 7 May 2026 20:43:08 +0300 Subject: [PATCH 1/6] Add test helper functions for controlling HTML ID generation --- CHANGELOG.md | 2 +- README.md | 1 + docs/testing-id-generation.md | 107 ++++++++++++++++++++++++++++++++++ src/Html.php | 22 +++---- src/IdGenerator.php | 35 +++++++++++ src/test-functions.php | 31 ++++++++++ 6 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 docs/testing-id-generation.md create mode 100644 src/IdGenerator.php create mode 100644 src/test-functions.php 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..6a55f773 --- /dev/null +++ b/docs/testing-id-generation.md @@ -0,0 +1,107 @@ +# 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/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 +final class MyTest extends TestCase +{ + public function testRendersLabel(): void + { + \Yiisoft\Html\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 +final class MyTest extends TestCase +{ + protected function setUp(): void + { + \Yiisoft\Html\IdGenerator\disableSeed(); + \Yiisoft\Html\IdGenerator\reset(); + } + + protected function tearDown(): void + { + \Yiisoft\Html\IdGenerator\enableSeed(); + } + + public function testRendersLabel(): void + { + // IDs are now i1, i2, … — safe to hardcode in assertions + $this->assertSame('', ...); + } +} +``` diff --git a/src/Html.php b/src/Html.php index 9bebe1d0..481b9cd1 100644 --- a/src/Html.php +++ b/src/Html.php @@ -120,27 +120,21 @@ final class Html private const ATTRIBUTES_WITH_CONCATENATED_VALUES = ['class', 'aria-describedby']; /** - * @var array - */ - 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..c4e15638 --- /dev/null +++ b/src/IdGenerator.php @@ -0,0 +1,35 @@ + + */ + 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; + 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..0f0789c1 --- /dev/null +++ b/src/test-functions.php @@ -0,0 +1,31 @@ + Date: Thu, 7 May 2026 20:52:39 +0300 Subject: [PATCH 2/6] improve tests --- docs/testing-id-generation.md | 3 +++ phpunit.xml.dist | 2 +- tests/HtmlGenerateIdTest.php | 31 +++++++++++++++++++++++++++++++ tests/HtmlTest.php | 27 --------------------------- tests/bootstrap.php | 6 ++++++ tests/mocks/hrtime.php | 12 ------------ 6 files changed, 41 insertions(+), 40 deletions(-) create mode 100644 tests/HtmlGenerateIdTest.php create mode 100644 tests/bootstrap.php delete mode 100644 tests/mocks/hrtime.php diff --git a/docs/testing-id-generation.md b/docs/testing-id-generation.md index 6a55f773..1f20885a 100644 --- a/docs/testing-id-generation.md +++ b/docs/testing-id-generation.md @@ -44,7 +44,10 @@ In `phpunit.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 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 @@ assertMatchesRegularExpression('/i\d+/', Html::generateId()); + $this->assertMatchesRegularExpression('/test\d+/', Html::generateId('test')); + + \Yiisoft\Html\IdGenerator\disableSeed(); + \Yiisoft\Html\IdGenerator\reset(); + $this->assertSame('i1', Html::generateId()); + $this->assertSame('i2', Html::generateId()); + + \Yiisoft\Html\IdGenerator\reset(); + $this->assertSame('i1', 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 @@ + Date: Thu, 7 May 2026 21:01:05 +0300 Subject: [PATCH 3/6] improve --- src/IdGenerator.php | 6 +++- tests/HtmlGenerateIdTest.php | 53 +++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/IdGenerator.php b/src/IdGenerator.php index c4e15638..abf38705 100644 --- a/src/IdGenerator.php +++ b/src/IdGenerator.php @@ -28,7 +28,11 @@ public static function generate(string $prefix): string $count = ++self::$counter[$prefix]; } else { $count = 1; - self::$counter = [$prefix => $count]; + if (self::$useSeed) { + self::$counter = [$prefix => $count]; + } else { + self::$counter[$prefix] = $count; + } } return $prefix . $count; } diff --git a/tests/HtmlGenerateIdTest.php b/tests/HtmlGenerateIdTest.php index 13f5da10..13a93f3e 100644 --- a/tests/HtmlGenerateIdTest.php +++ b/tests/HtmlGenerateIdTest.php @@ -14,18 +14,63 @@ protected function tearDown(): void \Yiisoft\Html\IdGenerator\enableSeed(); } - public function testGenerateId(): void + public function testGenerateIdWithSeedEnabled(): void { - \Yiisoft\Html\IdGenerator\enableSeed(); - $this->assertMatchesRegularExpression('/i\d+/', Html::generateId()); - $this->assertMatchesRegularExpression('/test\d+/', Html::generateId('test')); + $this->assertMatchesRegularExpression('/^i\d+$/', Html::generateId()); + $this->assertMatchesRegularExpression('/^test\d+$/', Html::generateId('test')); + } + + public function testGenerateIdWithSeedDisabled(): void + { + \Yiisoft\Html\IdGenerator\disableSeed(); + \Yiisoft\Html\IdGenerator\reset(); + + $this->assertSame('i1', Html::generateId()); + $this->assertSame('i2', Html::generateId()); + $this->assertSame('i3', Html::generateId()); + } + + public function testGenerateIdWithSeedDisabledCustomPrefix(): void + { + \Yiisoft\Html\IdGenerator\disableSeed(); + \Yiisoft\Html\IdGenerator\reset(); + + $this->assertSame('test1', Html::generateId('test')); + $this->assertSame('test2', Html::generateId('test')); + } + + public function testGenerateIdWithSeedDisabledMixedPrefixesDoNotResetEachOther(): void + { + \Yiisoft\Html\IdGenerator\disableSeed(); + \Yiisoft\Html\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 + { \Yiisoft\Html\IdGenerator\disableSeed(); \Yiisoft\Html\IdGenerator\reset(); + $this->assertSame('i1', Html::generateId()); $this->assertSame('i2', Html::generateId()); + \Yiisoft\Html\IdGenerator\reset(); + + $this->assertSame('i1', Html::generateId()); + } + + public function testEnableSeedRestoresTimestampBehavior(): void + { + \Yiisoft\Html\IdGenerator\disableSeed(); \Yiisoft\Html\IdGenerator\reset(); $this->assertSame('i1', Html::generateId()); + + \Yiisoft\Html\IdGenerator\enableSeed(); + + $this->assertMatchesRegularExpression('/^i\d{10,}/', Html::generateId()); } } From 507c588a7e767e9e4902785c3ce0f9522282adae Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 15 May 2026 14:02:23 +0300 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/HtmlGenerateIdTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/HtmlGenerateIdTest.php b/tests/HtmlGenerateIdTest.php index 13a93f3e..522d4e6b 100644 --- a/tests/HtmlGenerateIdTest.php +++ b/tests/HtmlGenerateIdTest.php @@ -11,7 +11,10 @@ final class HtmlGenerateIdTest extends TestCase { protected function tearDown(): void { + \Yiisoft\Html\IdGenerator\reset(); \Yiisoft\Html\IdGenerator\enableSeed(); + + parent::tearDown(); } public function testGenerateIdWithSeedEnabled(): void From b80621af70329a826f6c1398acc1d3c17d39a28e Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 15 May 2026 14:08:30 +0300 Subject: [PATCH 5/6] improve --- src/test-functions.php | 1 + tests/HtmlGenerateIdTest.php | 29 +++++++++++++++-------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/test-functions.php b/src/test-functions.php index 0f0789c1..3b9f007d 100644 --- a/src/test-functions.php +++ b/src/test-functions.php @@ -5,6 +5,7 @@ namespace Yiisoft\Html\IdGenerator; use Yiisoft\Html\IdGenerator; +use Yiisoft\Html\Html; /** * Resets the internal ID counter used by {@see Html::generateId()}. diff --git a/tests/HtmlGenerateIdTest.php b/tests/HtmlGenerateIdTest.php index 522d4e6b..02c1d4d5 100644 --- a/tests/HtmlGenerateIdTest.php +++ b/tests/HtmlGenerateIdTest.php @@ -6,13 +6,14 @@ use PHPUnit\Framework\TestCase; use Yiisoft\Html\Html; +use Yiisoft\Html\IdGenerator; final class HtmlGenerateIdTest extends TestCase { protected function tearDown(): void { - \Yiisoft\Html\IdGenerator\reset(); - \Yiisoft\Html\IdGenerator\enableSeed(); + IdGenerator\reset(); + IdGenerator\enableSeed(); parent::tearDown(); } @@ -25,8 +26,8 @@ public function testGenerateIdWithSeedEnabled(): void public function testGenerateIdWithSeedDisabled(): void { - \Yiisoft\Html\IdGenerator\disableSeed(); - \Yiisoft\Html\IdGenerator\reset(); + IdGenerator\disableSeed(); + IdGenerator\reset(); $this->assertSame('i1', Html::generateId()); $this->assertSame('i2', Html::generateId()); @@ -35,8 +36,8 @@ public function testGenerateIdWithSeedDisabled(): void public function testGenerateIdWithSeedDisabledCustomPrefix(): void { - \Yiisoft\Html\IdGenerator\disableSeed(); - \Yiisoft\Html\IdGenerator\reset(); + IdGenerator\disableSeed(); + IdGenerator\reset(); $this->assertSame('test1', Html::generateId('test')); $this->assertSame('test2', Html::generateId('test')); @@ -44,8 +45,8 @@ public function testGenerateIdWithSeedDisabledCustomPrefix(): void public function testGenerateIdWithSeedDisabledMixedPrefixesDoNotResetEachOther(): void { - \Yiisoft\Html\IdGenerator\disableSeed(); - \Yiisoft\Html\IdGenerator\reset(); + IdGenerator\disableSeed(); + IdGenerator\reset(); $this->assertSame('i1', Html::generateId()); $this->assertSame('test1', Html::generateId('test')); @@ -55,24 +56,24 @@ public function testGenerateIdWithSeedDisabledMixedPrefixesDoNotResetEachOther() public function testResetClearsCounterWithoutChangingSeedMode(): void { - \Yiisoft\Html\IdGenerator\disableSeed(); - \Yiisoft\Html\IdGenerator\reset(); + IdGenerator\disableSeed(); + IdGenerator\reset(); $this->assertSame('i1', Html::generateId()); $this->assertSame('i2', Html::generateId()); - \Yiisoft\Html\IdGenerator\reset(); + IdGenerator\reset(); $this->assertSame('i1', Html::generateId()); } public function testEnableSeedRestoresTimestampBehavior(): void { - \Yiisoft\Html\IdGenerator\disableSeed(); - \Yiisoft\Html\IdGenerator\reset(); + IdGenerator\disableSeed(); + IdGenerator\reset(); $this->assertSame('i1', Html::generateId()); - \Yiisoft\Html\IdGenerator\enableSeed(); + IdGenerator\enableSeed(); $this->assertMatchesRegularExpression('/^i\d{10,}/', Html::generateId()); } From 6c6813a2e982cff41e6767ba68de4328718282bc Mon Sep 17 00:00:00 2001 From: Sergei Predvoditelev Date: Fri, 15 May 2026 14:16:00 +0300 Subject: [PATCH 6/6] improve docs --- docs/testing-id-generation.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/testing-id-generation.md b/docs/testing-id-generation.md index 1f20885a..9efa9f87 100644 --- a/docs/testing-id-generation.md +++ b/docs/testing-id-generation.md @@ -66,11 +66,13 @@ require_once 'vendor/yiisoft/html/src/test-functions.php'; In the test class: ```php +use Yiisoft\Html\IdGenerator; + final class MyTest extends TestCase { public function testRendersLabel(): void { - \Yiisoft\Html\IdGenerator\reset(); + IdGenerator\reset(); $this->assertSame('', ...); } @@ -88,17 +90,19 @@ from `i1`. Add `enableSeed()` in `tearDown()` to restore the default behaviour a finishes: ```php +use Yiisoft\Html\IdGenerator; + final class MyTest extends TestCase { protected function setUp(): void { - \Yiisoft\Html\IdGenerator\disableSeed(); - \Yiisoft\Html\IdGenerator\reset(); + IdGenerator\disableSeed(); + IdGenerator\reset(); } protected function tearDown(): void { - \Yiisoft\Html\IdGenerator\enableSeed(); + IdGenerator\enableSeed(); } public function testRendersLabel(): void