<?php
declare(strict_types=1);
namespace Sabberworm\CSS\Tests\Unit\RuleSet;
use PHPUnit\Framework\TestCase;
use Sabberworm\CSS\Comment\Comment;
use Sabberworm\CSS\CSSElement;
use Sabberworm\CSS\CSSList\CSSListItem;
use Sabberworm\CSS\Parsing\ParserState;
use Sabberworm\CSS\Parsing\UnexpectedTokenException;
use Sabberworm\CSS\Position\Positionable;
use Sabberworm\CSS\Property\Declaration;
use Sabberworm\CSS\Property\Selector;
use Sabberworm\CSS\RuleSet\DeclarationBlock;
use Sabberworm\CSS\RuleSet\RuleSet;
use Sabberworm\CSS\Settings;
use TRegx\PhpUnit\DataProviders\DataProvider;
* @covers \Sabberworm\CSS\RuleSet\DeclarationBlock
*/
final class DeclarationBlockTest extends TestCase
{
use DeclarationListTests;
* @var DeclarationBlock
*/
private $subject;
protected function setUp(): void
{
$this->subject = new DeclarationBlock();
}
* @test
*/
public function implementsCSSElement(): void
{
self::assertInstanceOf(CSSElement::class, $this->subject);
}
* @test
*/
public function implementsCSSListItem(): void
{
self::assertInstanceOf(CSSListItem::class, $this->subject);
}
* @test
*/
public function implementsPositionable(): void
{
self::assertInstanceOf(Positionable::class, $this->subject);
}
* @test
*/
public function getLineNumberByDefaultReturnsNull(): void
{
$result = $this->subject->getLineNumber();
self::assertNull($result);
}
* @return array<non-empty-string, array{0: int<1, max>|null}>
*/
public function provideLineNumber(): array
{
return [
'null' => [null],
'line 1' => [1],
'line 42' => [42],
];
}
* @test
*
* @param int<1, max>|null $lineNumber
*
* @dataProvider provideLineNumber
*/
public function getLineNumberReturnsLineNumberPassedToConstructor(?int $lineNumber): void
{
$subject = new DeclarationBlock($lineNumber);
$result = $subject->getLineNumber();
self::assertSame($lineNumber, $result);
}
* @return array<non-empty-string, array{0: non-empty-string}>
*/
public static function provideSelector(): array
{
return [
'type' => ['body'],
'class' => ['.teapot'],
'type & class' => ['img.teapot'],
'id' => ['#my-mug'],
'type & id' => ['h2#my-mug'],
'pseudo-class' => [':hover'],
'type & pseudo-class' => ['a:hover'],
'`not`' => [':not(#your-mug)'],
'`not` with multiple arguments' => [':not(#your-mug, .their-mug)'],
'pseudo-element' => ['::before'],
'attribute with `"`' => ['[alt="{}()[]\\"\',"]'],
'attribute with `\'`' => ['[alt=\'{}()[]"\\\',\']'],
];
}
* @test
*
* @param non-empty-string $selector
*
* @dataProvider provideSelector
*/
public function parsesSingleSelector(string $selector): void
{
$subject = DeclarationBlock::parse(new ParserState($selector . ' {}', Settings::create()));
self::assertInstanceOf(DeclarationBlock::class, $subject);
self::assertSame([$selector], self::getSelectorsAsStrings($subject));
}
* @return DataProvider<non-empty-string, array{0: non-empty-string, 1: non-empty-string}>
*/
public static function provideTwoSelectors(): DataProvider
{
return DataProvider::cross(self::provideSelector(), self::provideSelector());
}
* @test
*
* @param non-empty-string $firstSelector
* @param non-empty-string $secondSelector
*
* @dataProvider provideTwoSelectors
*/
public function parsesTwoCommaSeparatedSelectors(string $firstSelector, string $secondSelector): void
{
$joinedSelectors = $firstSelector . ', ' . $secondSelector;
$subject = DeclarationBlock::parse(new ParserState($joinedSelectors . ' {}', Settings::create()));
self::assertInstanceOf(DeclarationBlock::class, $subject);
self::assertSame([$firstSelector, $secondSelector], self::getSelectorsAsStrings($subject));
}
* @return array<non-empty-string, array{0: non-empty-string, 1: non-empty-string}>
*/
public static function provideSelectorWithAndWithoutComment(): array
{
return [
'comment before' => ['/*comment*/body', 'body'],
'comment after' => ['body/*comment*/', 'body'],
'comment within' => ['./*comment*/teapot', '.teapot'],
'comment within function' => [':not(#your-mug,/*comment*/.their-mug)', ':not(#your-mug,.their-mug)'],
];
}
* @test
*
* @param non-empty-string $selectorWith
* @param non-empty-string $selectorWithout
*
* @dataProvider provideSelectorWithAndWithoutComment
*/
public function parsesSelectorWithComment(string $selectorWith, string $selectorWithout): void
{
$subject = DeclarationBlock::parse(new ParserState($selectorWith . ' {}', Settings::create()));
self::assertInstanceOf(DeclarationBlock::class, $subject);
self::assertSame([$selectorWithout], self::getSelectorsAsStrings($subject));
}
* @test
*
* @param non-empty-string $selector
*
* @dataProvider provideSelectorWithAndWithoutComment
*/
public function parseExtractsCommentFromSelector(string $selector): void
{
$subject = DeclarationBlock::parse(new ParserState($selector . ' {}', Settings::create()));
self::assertInstanceOf(DeclarationBlock::class, $subject);
self::assertSame(['comment'], self::getCommentsAsStrings($subject));
}
* @test
*/
public function parsesSelectorWithTwoComments(): void
{
$subject = DeclarationBlock::parse(new ParserState('/*comment1*/a/*comment2*/ {}', Settings::create()));
self::assertInstanceOf(DeclarationBlock::class, $subject);
self::assertSame(['a'], self::getSelectorsAsStrings($subject));
}
* @test
*/
public function parseExtractsTwoCommentsFromSelector(): void
{
$subject = DeclarationBlock::parse(new ParserState('/*comment1*/a/*comment2*/ {}', Settings::create()));
self::assertInstanceOf(DeclarationBlock::class, $subject);
self::assertSame(['comment1', 'comment2'], self::getCommentsAsStrings($subject));
}
* @return array<non-empty-string, array{0: string, 1: non-empty-string}>
*/
public static function provideInvalidSelectorAndExpectedExceptionMessage(): array
{
return [
'no selector' => ['', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'],
'lone `(`' => ['(', 'Token “)” (literal) not found. Got “{”. [line no: 1]'],
'lone `)`' => [')', 'Token “anything but” (literal) not found. Got “)”. [line no: 1]'],
'lone `,`' => [',', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'],
'unclosed `(`' => [':not(#your-mug', 'Token “)” (literal) not found. Got “{”. [line no: 1]'],
'extra `)`' => [':not(#your-mug))', 'Token “anything but” (literal) not found. Got “)”. [line no: 1]'],
'`,` missing left operand' => [', a', 'Token “selector” (literal) not found. Got “,”. [line no: 1]'],
'`,` missing right operand' => ['a,', 'Token “selector” (literal) not found. Got “{”. [line no: 1]'],
];
}
* @return array<non-empty-string, array{0: string}>
*/
public static function provideInvalidSelector(): array
{
return \array_map(
* @param array{0: string, 1: non-empty-string}
*
* @return array<{0: string}>
*/
static function (array $testData): array {
return [$testData[0]];
},
self::provideInvalidSelectorAndExpectedExceptionMessage()
);
}
* @test
*
* @dataProvider provideInvalidSelector
*/
public function parseSkipsBlockWithInvalidSelector(string $selector): void
{
static $nextCss = ' .next {}';
$css = $selector . ' {}' . $nextCss;
$parserState = new ParserState($css, Settings::create());
$subject = DeclarationBlock::parse($parserState);
self::assertNull($subject);
self::assertTrue($parserState->comes($nextCss));
}
* @test
*
* @param non-empty-string $expectedExceptionMessage
*
* @dataProvider provideInvalidSelectorAndExpectedExceptionMessage
*/
public function parseInStrictModeThrowsExceptionWithInvalidSelector(
string $selector,
string $expectedExceptionMessage
): void {
$this->expectException(UnexpectedTokenException::class);
$this->expectExceptionMessage($expectedExceptionMessage);
$parserState = new ParserState($selector . ' {}', Settings::create()->beStrict());
DeclarationBlock::parse($parserState);
}
* @return array<non-empty-string, array{0: non-empty-string}>
*/
public static function provideClosingBrace(): array
{
return [
'as is' => ['}'],
'with space before' => [' }'],
'with newline before' => ["\n}"],
];
}
* @return DataProvider<non-empty-string, array{0: non-empty-string, 1: non-empty-string}>
*/
public static function provideInvalidSelectorAndClosingBrace(): DataProvider
{
return DataProvider::cross(self::provideInvalidSelector(), self::provideClosingBrace());
}
* TODO: It's probably not the responsibility of `DeclarationBlock` to deal with this.
*
* @test
*
* @param non-empty-string $selector
* @param non-empty-string $closingBrace
*
* @dataProvider provideInvalidSelectorAndClosingBrace
*/
public function parseConsumesClosingBraceAfterInvalidSelector(string $selector, string $closingBrace): void
{
$parserState = new ParserState($selector . $closingBrace, Settings::create());
DeclarationBlock::parse($parserState);
self::assertTrue($parserState->isEnd());
}
* @return array<non-empty-string, array{0: string}>
*/
public static function provideOptionalWhitespace(): array
{
return [
'none' => [''],
'space' => [' '],
'newline' => ["\n"],
];
}
* @return DataProvider<non-empty-string, array{0: non-empty-string, 1: string}>
*/
public static function provideInvalidSelectorAndOptionalWhitespace(): DataProvider
{
return DataProvider::cross(self::provideInvalidSelector(), self::provideOptionalWhitespace());
}
* TODO: It's probably not the responsibility of `DeclarationBlock` to deal with this.
*
* @test
*
* @param non-empty-string $selector
*
* @dataProvider provideInvalidSelectorAndOptionalWhitespace
*/
public function parseConsumesToEofIfNoClosingBraceAfterInvalidSelector(
string $selector,
string $optionalWhitespace
): void {
$parserState = new ParserState($selector . $optionalWhitespace, Settings::create());
DeclarationBlock::parse($parserState);
self::assertTrue($parserState->isEnd());
}
* @test
*/
public function getRuleSetOnVirginReturnsARuleSet(): void
{
$result = $this->subject->getRuleSet();
self::assertInstanceOf(RuleSet::class, $result);
}
* @test
*/
public function getRuleSetAfterRulesSetReturnsARuleSet(): void
{
$this->subject->setDeclarations([new Declaration('color')]);
$result = $this->subject->getRuleSet();
self::assertInstanceOf(RuleSet::class, $result);
}
* @test
*/
public function getRuleSetOnVirginReturnsObjectWithoutDeclarations(): void
{
$result = $this->subject->getRuleSet();
self::assertSame([], $result->getDeclarations());
}
* @test
*
* @param list<string> $propertyNamesToSet
*
* @dataProvider providePropertyNames
*/
public function getRuleSetReturnsObjectWithDeclarationsSet(array $propertyNamesToSet): void
{
$declarations = self::createDeclarationsFromPropertyNames($propertyNamesToSet);
$this->subject->setDeclarations($declarations);
$result = $this->subject->getRuleSet();
self::assertSame($declarations, $result->getDeclarations());
}
* @test
*/
public function getRuleSetByDefaultReturnsObjectWithNullLineNumber(): void
{
$result = $this->subject->getRuleSet();
self::assertNull($result->getLineNumber());
}
* @test
*
* @param int<1, max>|null $lineNumber
*
* @dataProvider provideLineNumber
*/
public function getRuleSetReturnsObjectWithLineNumberPassedToConstructor(?int $lineNumber): void
{
$subject = new DeclarationBlock($lineNumber);
$result = $subject->getRuleSet();
self::assertSame($lineNumber, $result->getLineNumber());
}
* @test
*
* Any type of array may be passed to the method, but the resultant property should be a `list`.
*/
public function setSelectorsIgnoresKeys(): void
{
$subject = new DeclarationBlock();
$subject->setSelectors(['Bob' => 'html', 'Mary' => 'body']);
$result = $subject->getSelectors();
self::assertSame([0, 1], \array_keys($result));
}
* @test
*
* @param non-empty-string $selector
*
* @dataProvider provideSelector
*/
public function setSelectorsSetsSingleSelectorProvidedAsString(string $selector): void
{
$subject = new DeclarationBlock();
$subject->setSelectors($selector);
self::assertSame([$selector], self::getSelectorsAsStrings($subject));
}
* @test
*
* @param non-empty-string $firstSelector
* @param non-empty-string $secondSelector
*
* @dataProvider provideTwoSelectors
*/
public function setSelectorsSetsTwoCommaSeparatedSelectorsProvidedAsString(
string $firstSelector,
string $secondSelector
): void {
$joinedSelectors = $firstSelector . ', ' . $secondSelector;
$subject = new DeclarationBlock();
$subject->setSelectors($joinedSelectors);
self::assertSame([$firstSelector, $secondSelector], self::getSelectorsAsStrings($subject));
}
* Provides selectors that would be parsed without error in the context of full CSS, but are nonetheless invalid.
*
* @return array<non-empty-string, array{0: non-empty-string}>
*/
public static function provideInvalidStandaloneSelector(): array
{
return [
'rogue `{`' => ['a { b'],
'rogue `}`' => ['a } b'],
];
}
* @test
*
* @param non-empty-string $selector
*
* @dataProvider provideInvalidSelector
* @dataProvider provideInvalidStandaloneSelector
*/
public function setSelectorsThrowsExceptionWithInvalidSelector(string $selector): void
{
$this->expectException(UnexpectedTokenException::class);
$this->expectExceptionMessageMatches('/^Selector\\(s\\) string is not valid./');
$subject = new DeclarationBlock();
$subject->setSelectors($selector);
}
* @test
*/
public function getArrayRepresentationThrowsException(): void
{
$this->expectException(\BadMethodCallException::class);
$this->subject->getArrayRepresentation();
}
* @return list<string>
*/
private static function getSelectorsAsStrings(DeclarationBlock $declarationBlock): array
{
return \array_map(
static function (Selector $selectorObject): string {
return $selectorObject->getSelector();
},
$declarationBlock->getSelectors()
);
}
* @return list<string>
*/
private static function getCommentsAsStrings(DeclarationBlock $declarationBlock): array
{
return \array_map(
static function (Comment $comment): string {
return $comment->getComment();
},
$declarationBlock->getComments()
);
}
}