* @copyright 2002-2026 Nicola Asuni - Tecnick.com LTD * @license https://www.gnu.org/copyleft/lesser.html GNU-LGPL v3 (see LICENSE) * @link https://github.com/tecnickcom/tc-lib-pdf * * This file is part of tc-lib-pdf software library. */ namespace Test; use Com\Tecnick\Pdf\CSS\Specificity; use PHPUnit\Framework\Attributes\DataProvider; class SpecificityTest extends TestUtil { public function testConstructorInitializesValues(): void { $spec = new Specificity(1, 2, 3); $this->assertSame(1, $spec->idCount); $this->assertSame(2, $spec->classCount); $this->assertSame(3, $spec->typeCount); } public function testConstructorNormalizesNegativeValues(): void { $spec = new Specificity(-1, -2, -3); $this->assertSame(0, $spec->idCount); $this->assertSame(0, $spec->classCount); $this->assertSame(0, $spec->typeCount); } #[DataProvider('selectorSpecificityProvider')] public function testFromSelectorCalculatesCorrectSpecificity( string $selector, int $expectedA, int $expectedB, int $expectedC, ): void { $spec = Specificity::fromSelector($selector); $this->assertSame($expectedA, $spec->idCount, "ID count mismatch for: {$selector}"); $this->assertSame( $expectedB, $spec->classCount, "Class/attribute/pseudo-class count mismatch for: {$selector}", ); $this->assertSame($expectedC, $spec->typeCount, "Type/pseudo-element count mismatch for: {$selector}"); } /** @return array */ public static function selectorSpecificityProvider(): array { return [ 'universal selector' => ['*', 0, 0, 0], 'single type' => ['div', 0, 0, 1], 'single class' => ['.highlight', 0, 1, 0], 'single id' => ['#main', 1, 0, 0], 'type with class' => ['div.highlight', 0, 1, 1], 'type with id' => ['div#main', 1, 0, 1], 'type with attribute' => ['div[role="button"]', 0, 1, 1], 'type with pseudo-class' => ['div:hover', 0, 1, 1], 'descendant combinator' => ['div p', 0, 0, 2], 'child combinator' => ['div > p', 0, 0, 2], 'adjacent sibling' => ['h1 + p', 0, 0, 2], 'general sibling' => ['h1 ~ p', 0, 0, 2], 'multiple ids' => ['#main #article', 2, 0, 0], 'multiple classes' => ['.header.highlight.active', 0, 3, 0], 'complex selector 1' => ['.header nav li a:hover', 0, 2, 3], 'complex selector 2' => ['#nav .menu li a:visited', 1, 2, 2], 'multiple pseudo-classes' => ['a:link:visited:hover', 0, 3, 1], 'pseudo-element before' => ['p::before', 0, 0, 2], 'pseudo-element after' => ['p::after', 0, 0, 2], 'type and pseudo-element' => ['div::before', 0, 0, 2], 'multiple attributes' => ['div[data-foo][data-bar]', 0, 2, 1], ]; } #[DataProvider('specificityComparisonProvider')] public function testComparisonMethods( int $idCount1, int $classCount1, int $typeCount1, int $idCount2, int $classCount2, int $typeCount2, int $expectedComparison, ): void { $spec1 = new Specificity($idCount1, $classCount1, $typeCount1); $spec2 = new Specificity($idCount2, $classCount2, $typeCount2); $result = $spec1->compareTo($spec2); $this->assertSame($expectedComparison, $result); if ($expectedComparison < 0) { $this->assertTrue($spec1->isLessThan($spec2)); $this->assertFalse($spec1->isGreaterThan($spec2)); $this->assertFalse($spec1->equals($spec2)); } elseif ($expectedComparison > 0) { $this->assertTrue($spec1->isGreaterThan($spec2)); $this->assertFalse($spec1->isLessThan($spec2)); $this->assertFalse($spec1->equals($spec2)); } else { $this->assertTrue($spec1->equals($spec2)); $this->assertFalse($spec1->isLessThan($spec2)); $this->assertFalse($spec1->isGreaterThan($spec2)); } } /** * @return array */ public static function specificityComparisonProvider(): array { return [ 'equal' => [0, 0, 0, 0, 0, 0, 0], 'a determines precedence' => [1, 0, 0, 0, 9, 9, 1], 'b determines when a equal' => [0, 1, 0, 0, 0, 9, 1], 'c determines when a and b equal' => [0, 0, 1, 0, 0, 0, 1], 'less: all components' => [0, 0, 0, 1, 1, 1, -1], 'greater: all components' => [1, 1, 1, 0, 0, 0, 1], 'edge case: high b vs low a' => [0, 99, 99, 1, 0, 0, -1], 'edge case: high c vs low a' => [0, 0, 99, 1, 0, 0, -1], 'edge case: all high vs single a' => [0, 99, 99, 1, 0, 0, -1], ]; } public function testSortKeyFormatting(): void { $spec = new Specificity(1, 2, 3); $key = $spec->toSortKey(0); $this->assertStringMatchesFormat('%s_%s', $key); $this->assertStringContainsString('0001', $key); $this->assertStringContainsString('0002', $key); $this->assertStringContainsString('0003', $key); $this->assertStringContainsString('000000', $key); } public function testSortKeyWithSourceOrder(): void { $spec = new Specificity(1, 2, 3); $key1 = $spec->toSortKey(0); $key2 = $spec->toSortKey(1); // Same specificity, different source order - should be ordered by index $this->assertLessThan(0, \strcmp($key1, $key2)); } public function testSortKeyOrderingBySpecificity(): void { $low = new Specificity(0, 0, 1); $medium = new Specificity(0, 1, 0); $high = new Specificity(1, 0, 0); $keys = [ $high->toSortKey(0), $low->toSortKey(0), $medium->toSortKey(0), ]; $sorted = $keys; \sort($sorted); // After string sort, low < medium < high $this->assertSame($low->toSortKey(0), $sorted[0]); assert(isset($sorted[1]), "\$sorted[1] must be set"); $this->assertSame($medium->toSortKey(0), $sorted[1]); assert(isset($sorted[2]), "\$sorted[2] must be set"); $this->assertSame($high->toSortKey(0), $sorted[2]); } public function testToStringRepresentation(): void { $spec = new Specificity(1, 2, 3); $str = $spec->toString(); $this->assertSame('(1,2,3)', $str); } public function testLegacyStringConversion(): void { $spec = new Specificity(1, 2, 3); $legacy = $spec->toLegacyString(0); $this->assertSame('0123', $legacy); $legacyInline = $spec->toLegacyString(1); $this->assertSame('1123', $legacyInline); } public function testLegacyStringParsing(): void { $spec = Specificity::fromLegacyString('0123'); $this->assertSame(1, $spec->idCount); $this->assertSame(2, $spec->classCount); $this->assertSame(3, $spec->typeCount); } public function testLegacyStringRoundTrip(): void { $original = new Specificity(2, 3, 4); $legacy = $original->toLegacyString(0); $restored = Specificity::fromLegacyString($legacy); $this->assertTrue($original->equals($restored)); } public function testRealWorldSelectorComparisons(): void { // From CSS spec examples $headingOne = Specificity::fromSelector('h1'); $h1_foo = Specificity::fromSelector('h1.foo'); $h1_foo_bar = Specificity::fromSelector('h1.foo.bar'); $div_p_a = Specificity::fromSelector('div p a'); $foo_bar_baz = Specificity::fromSelector('.foo .bar .baz'); $id_selector = Specificity::fromSelector('#main'); // h1 (0,0,1) < h1.foo (0,1,1) $this->assertTrue($headingOne->isLessThan($h1_foo)); // h1.foo (0,1,1) < h1.foo.bar (0,2,1) $this->assertTrue($h1_foo->isLessThan($h1_foo_bar)); // div p a (0,0,3) < .foo .bar .baz (0,3,0) $this->assertTrue($div_p_a->isLessThan($foo_bar_baz)); // All < #main (1,0,0) $this->assertTrue($foo_bar_baz->isLessThan($id_selector)); $this->assertTrue($h1_foo_bar->isLessThan($id_selector)); } }