diff --git a/composer.json b/composer.json index 1c47704..1d3d032 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,11 @@ "Gt\\DomTemplate\\Test\\": "./test/phpunit" } }, + "config": { + "platform": { + "php": "8.2.0" + } + }, "scripts": { "phpunit": "vendor/bin/phpunit --configuration phpunit.xml", diff --git a/composer.lock b/composer.lock index a45151f..2718da4 100644 --- a/composer.lock +++ b/composer.lock @@ -4,30 +4,32 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "65c2a4683509c023e7a1a76aeaeb81a6", + "content-hash": "aa5f6152b5c43785a49c393cde70b638", "packages": [ { "name": "phpgt/cssxpath", - "version": "v1.4.0", + "version": "v1.5.0", "source": { "type": "git", "url": "https://github.com/phpgt/CssXPath.git", - "reference": "008aaf7f381e2396590fc53830f4a8ad9f517c7f" + "reference": "577781742ff61742332740d50f35f2b6cc037e2a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpgt/CssXPath/zipball/008aaf7f381e2396590fc53830f4a8ad9f517c7f", - "reference": "008aaf7f381e2396590fc53830f4a8ad9f517c7f", + "url": "https://api.github.com/repos/phpgt/CssXPath/zipball/577781742ff61742332740d50f35f2b6cc037e2a", + "reference": "577781742ff61742332740d50f35f2b6cc037e2a", "shasum": "" }, "require": { - "php": ">=8.0" + "php": ">=8.2" }, "require-dev": { "ext-dom": "*", "ext-libxml": "*", + "phpmd/phpmd": "^2.15", "phpstan/phpstan": "^2.1", - "phpunit/phpunit": "^9.6" + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^4.0" }, "type": "library", "autoload": { @@ -50,7 +52,7 @@ "description": "Convert CSS selectors to XPath queries.", "support": { "issues": "https://github.com/phpgt/CssXPath/issues", - "source": "https://github.com/phpgt/CssXPath/tree/v1.4.0" + "source": "https://github.com/phpgt/CssXPath/tree/v1.5.0" }, "funding": [ { @@ -58,7 +60,7 @@ "type": "github" } ], - "time": "2025-10-24T09:11:43+00:00" + "time": "2026-03-16T12:14:14+00:00" }, { "name": "phpgt/dom", @@ -796,11 +798,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -845,7 +847,7 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -2526,16 +2528,16 @@ }, { "name": "symfony/config", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5" + "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/6c17162555bfb58957a55bb0e43e00035b6ae3d5", - "reference": "6c17162555bfb58957a55bb0e43e00035b6ae3d5", + "url": "https://api.github.com/repos/symfony/config/zipball/2d19dde43fa2ff720b9a40763ace7226594f503b", + "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b", "shasum": "" }, "require": { @@ -2581,7 +2583,7 @@ "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v7.4.7" + "source": "https://github.com/symfony/config/tree/v7.4.8" }, "funding": [ { @@ -2601,20 +2603,20 @@ "type": "tidelift" } ], - "time": "2026-03-06T10:41:14+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/dependency-injection", - "version": "v7.4.7", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/dependency-injection.git", - "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db" + "reference": "f7025fd7b687c240426562f86ada06a93b1e771d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", - "reference": "0f651e58f4917fb0e2cd261ccbfe3d71e6e0f5db", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f7025fd7b687c240426562f86ada06a93b1e771d", + "reference": "f7025fd7b687c240426562f86ada06a93b1e771d", "shasum": "" }, "require": { @@ -2665,7 +2667,7 @@ "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v7.4.7" + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.8" }, "funding": [ { @@ -2685,7 +2687,7 @@ "type": "tidelift" } ], - "time": "2026-03-03T07:48:48+00:00" + "time": "2026-03-31T06:50:29+00:00" }, { "name": "symfony/deprecation-contracts", @@ -2756,16 +2758,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e" + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/3ebc794fa5315e59fd122561623c2e2e4280538e", - "reference": "3ebc794fa5315e59fd122561623c2e2e4280538e", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", "shasum": "" }, "require": { @@ -2802,7 +2804,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.6" + "source": "https://github.com/symfony/filesystem/tree/v7.4.8" }, "funding": [ { @@ -2822,7 +2824,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/polyfill-ctype", @@ -3081,16 +3083,16 @@ }, { "name": "symfony/var-exporter", - "version": "v7.4.0", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" + "reference": "398907e89a2a56fe426f7955c6fa943ec0c77225" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", - "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/398907e89a2a56fe426f7955c6fa943ec0c77225", + "reference": "398907e89a2a56fe426f7955c6fa943ec0c77225", "shasum": "" }, "require": { @@ -3138,7 +3140,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.8" }, "funding": [ { @@ -3158,7 +3160,7 @@ "type": "tidelift" } ], - "time": "2025-09-11T10:15:23+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "theseer/tokenizer", @@ -3221,5 +3223,8 @@ "ext-dom": "*" }, "platform-dev": {}, + "platform-overrides": { + "php": "8.2.0" + }, "plugin-api-version": "2.9.0" } diff --git a/src/DocumentBinder.php b/src/DocumentBinder.php index e39b8a5..ae4e8e1 100644 --- a/src/DocumentBinder.php +++ b/src/DocumentBinder.php @@ -12,6 +12,8 @@ class DocumentBinder extends Binder { protected ListBinder $listBinder; protected ListElementCollection $templateCollection; protected BindableCache $bindableCache; + private ?string $debugSource = null; + private int $debugSourceDepth = 0; public function __construct( protected readonly Document $document, @@ -41,11 +43,13 @@ public function bindValue( mixed $value, null|string|Element $context = null ):void { - if(is_string($context)) { - $context = $this->stringToContext($context); - } + $this->withDebugSource(function()use($value, $context):void { + if(is_string($context)) { + $context = $this->stringToContext($context); + } - $this->bind(null, $value, $context); + $this->bind(null, $value, $context); + }); } /** @@ -57,11 +61,13 @@ public function bindKeyValue( mixed $value, null|Element|string $context = null, ):void { - if(is_string($context)) { - $context = $this->stringToContext($context); - } + $this->withDebugSource(function()use($key, $value, $context):void { + if(is_string($context)) { + $context = $this->stringToContext($context); + } - $this->bind($key, $value, $context); + $this->bind($key, $value, $context); + }); } /** @@ -72,39 +78,41 @@ public function bindData( mixed $kvp, null|string|Element $context = null ):void { - if(is_string($context)) { - $context = $this->stringToContext($context); - } + $this->withDebugSource(function()use($kvp, $context):void { + if(is_string($context)) { + $context = $this->stringToContext($context); + } - if($this->isIndexedArray($kvp)) { - throw new IncompatibleBindDataException( - "bindData is only compatible with key-value-pair data, " - . "but it was passed an indexed array." - ); - } + if($this->isIndexedArray($kvp)) { + throw new IncompatibleBindDataException( + "bindData is only compatible with key-value-pair data, " + . "but it was passed an indexed array." + ); + } - if(is_object($kvp) && method_exists($kvp, "asArray")) { - $kvp = $kvp->asArray(); - } + if(is_object($kvp) && method_exists($kvp, "asArray")) { + $kvp = $kvp->asArray(); + } // The $kvp object may be both an object with its own key-value-pairs and // an iterable object. We can perform the two bind operations here. - $object = null; - if(is_object($kvp)) { - if($this->bindableCache->isBindable($kvp)) { - $object = $kvp; - $kvp = $this->bindableCache->convertToKvp($kvp); + $object = null; + if(is_object($kvp)) { + if($this->bindableCache->isBindable($kvp)) { + $object = $kvp; + $kvp = $this->bindableCache->convertToKvp($kvp); + } } - } - foreach($kvp ?? [] as $key => $value) { - $this->bindKeyValue($key, $value, $context); - } + foreach($kvp ?? [] as $key => $value) { + $this->bindKeyValue($key, $value, $context); + } - if(is_iterable($object)) { - $this->listBinder->bindListData($object, $context ?? $this->document); - } + if(is_iterable($object)) { + $this->listBinder->bindListData($object, $context ?? $this->document); + } + }); } public function bindTable( @@ -112,15 +120,18 @@ public function bindTable( null|string|Element $context = null, ?string $bindKey = null ):void { - if(is_string($context)) { - $context = $this->stringToContext($context); - } + $this->withDebugSource(function()use($tableData, $context, $bindKey):void { + if(is_string($context)) { + $context = $this->stringToContext($context); + } - $this->tableBinder->bindTableData( - $tableData, - $context ?? $this->document, - $bindKey - ); + $this->syncDebugSource(); + $this->tableBinder->bindTableData( + $tableData, + $context ?? $this->document, + $bindKey + ); + }); } /** @@ -131,15 +142,18 @@ public function bindList( null|string|Element $context = null, ?string $templateName = null ):int { - if(is_string($context)) { - $context = $this->stringToContext($context); - } + return $this->withDebugSource(function()use($listData, $context, $templateName):int { + if(is_string($context)) { + $context = $this->stringToContext($context); + } - if(!$context) { - $context = $this->document; - } + if(!$context) { + $context = $this->document; + } - return $this->listBinder->bindListData($listData, $context, $templateName); + $this->syncDebugSource(); + return $this->listBinder->bindListData($listData, $context, $templateName); + }); } /** @param iterable $listData */ @@ -149,16 +163,23 @@ public function bindListCallback( null|string|Element $context = null, ?string $templateName = null ):int { - if(!$context) { - $context = $this->document; - } + return $this->withDebugSource(function()use($listData, $callback, $context, $templateName):int { + if(is_string($context)) { + $context = $this->stringToContext($context); + } - return $this->listBinder->bindListData( - $listData, - $context, - $templateName, - $callback - ); + if(!$context) { + $context = $this->document; + } + + $this->syncDebugSource(); + return $this->listBinder->bindListData( + $listData, + $context, + $templateName, + $callback + ); + }); } public function cleanupDocument():void { @@ -177,6 +198,10 @@ public function cleanupDocument():void { } $ownerElement = $item->ownerElement; + if($item->name === "data-bind-debug" && $item->value !== "") { + continue; + } + if($ownerElement->hasAttribute("data-element")) { if(!$ownerElement->hasAttribute("data-bound")) { array_push($elementsToRemove, $ownerElement); @@ -212,6 +237,7 @@ protected function bind( $value = call_user_func($value); } + $this->syncDebugSource(); $this->elementBinder->bind($key, $value, $context); } @@ -232,4 +258,62 @@ private function isIndexedArray(mixed $data):bool { protected function stringToContext(string $context):Element { return $this->document->querySelector($context); } + + private function syncDebugSource():void { + $this->elementBinder->setDebugSource($this->debugSource); + $this->tableBinder->setDebugSource($this->debugSource); + } + + private function withDebugSource(callable $callback):mixed { + $isRootBindingCall = $this->debugSourceDepth === 0; + if($isRootBindingCall) { + $this->debugSource = $this->resolveDebugSource(); + } + + $this->debugSourceDepth++; + try { + return $callback(); + } + finally { + $this->debugSourceDepth--; + if($this->debugSourceDepth === 0) { + $this->debugSource = null; + if(isset($this->elementBinder)) { + $this->elementBinder->setDebugSource(null); + } + } + } + } + + private function resolveDebugSource():string { + $debugSource = "unknown:0"; + foreach(debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS) as $frame) { + $file = $frame["file"] ?? null; + $line = $frame["line"] ?? null; + if($file && $line) { + $debugSource = $this->normalizeDebugFile($file) . ":" . $line; + if(str_starts_with($debugSource, "src/") + || str_starts_with($debugSource, "vendor/")) { + continue; + } + + break; + } + } + + return $debugSource; + } + + private function normalizeDebugFile(string $file):string { + $cwd = getcwd(); + if($cwd) { + $cwd = rtrim(str_replace("\\", "/", $cwd), "/") . "/"; + $file = str_replace("\\", "/", $file); + if(str_starts_with($file, $cwd)) { + return substr($file, strlen($cwd)); + } + } + + return $file; + } } diff --git a/src/ElementBinder.php b/src/ElementBinder.php index 5dc644e..0f2ee89 100644 --- a/src/ElementBinder.php +++ b/src/ElementBinder.php @@ -8,6 +8,10 @@ class ElementBinder { private HTMLAttributeCollection $htmlAttributeCollection; private PlaceholderBinder $placeholderBinder; + public function setDebugSource(?string $debugSource):void { + $this->htmlAttributeBinder->setDebugSource($debugSource); + } + public function setDependencies( HTMLAttributeBinder $htmlAttributeBinder, HTMLAttributeCollection $htmlAttributeCollection, diff --git a/src/HTMLAttributeBinder.php b/src/HTMLAttributeBinder.php index 2750e8a..dae7473 100644 --- a/src/HTMLAttributeBinder.php +++ b/src/HTMLAttributeBinder.php @@ -9,12 +9,17 @@ class HTMLAttributeBinder { private ListBinder $listBinder; private TableBinder $tableBinder; + private ?string $debugSource = null; public function setDependencies(ListBinder $listBinder, TableBinder $tableBinder):void { $this->listBinder = $listBinder; $this->tableBinder = $tableBinder; } + public function setDebugSource(?string $debugSource):void { + $this->debugSource = $debugSource; + } + public function bind( ?string $key, mixed $value, @@ -49,6 +54,7 @@ public function bind( $bindValue, $modifier, ); + $this->appendDebugInfo($element, $bindProperty); $element->setAttribute("data-bound", ""); if(!$attribute->ownerElement->hasAttribute("data-rebind")) { $attributesToRemove[] = $attributeName; @@ -112,7 +118,27 @@ private function markBoundElement( } private function shouldHandleAttribute(string $attributeName):bool { - return str_starts_with($attributeName, "data-bind"); + return str_starts_with($attributeName, "data-bind:") + || $attributeName === "data-bind"; + } + + private function appendDebugInfo(Element $element, string $bindProperty):void { + if(!$this->debugSource || !$this->isDebugEnabled($element)) { + return; + } + + $entry = $bindProperty . "=" . $this->debugSource; + $debugAttribute = trim($element->getAttribute("data-bind-debug") ?? ""); + if($debugAttribute === "") { + $element->setAttribute("data-bind-debug", $entry); + return; + } + + $element->setAttribute("data-bind-debug", $debugAttribute . "," . $entry); + } + + private function isDebugEnabled(Element $element):bool { + return !is_null($element->closest("[data-bind-debug]")); } private function getBindProperty( diff --git a/src/TableBinder.php b/src/TableBinder.php index a38ab31..9e38ef5 100644 --- a/src/TableBinder.php +++ b/src/TableBinder.php @@ -22,6 +22,7 @@ class TableBinder { private HTMLAttributeBinder $htmlAttributeBinder; private HTMLAttributeCollection $htmlAttributeCollection; private PlaceholderBinder $placeholderBinder; + private ?string $debugSource = null; public function setDependencies( ListBinder $listBinder, @@ -39,12 +40,16 @@ public function setDependencies( $this->placeholderBinder = $placeholderBinder; } + public function setDebugSource(?string $debugSource):void { + $this->debugSource = $debugSource; + } + /** * @param BindTableDataInput $tableData * @param Element $context */ public function bindTableData( - array $tableData, + iterable $tableData, Document|Element $context, ?string $bindKey = null ):void { @@ -99,7 +104,15 @@ private function collectTables(Element $context):array { private function tableMatchesBindKey(Element $table, ?string $bindKey):bool { $dataBindTableElement = $table; if(!$dataBindTableElement->hasAttribute("data-bind:table")) { - $dataBindTableElement = $table->closest("[data-bind:table]") ?? $table; + $parent = $table->parentElement; + while($parent) { + if($parent->hasAttribute("data-bind:table")) { + $dataBindTableElement = $parent; + break; + } + + $parent = $parent->parentElement; + } } return $dataBindTableElement->getAttribute("data-bind:table") == $bindKey; @@ -139,6 +152,7 @@ private function resolveAllowedHeaders(Element $table, array $headerRow):array { foreach($headerRow as $headerValue) { $headerCell = $tableHeadRow->insertCell(); $headerCell->textContent = $headerValue; + $this->appendDebugInfo($headerCell, "text"); } return $headerRow; @@ -197,6 +211,7 @@ private function populateTableCells( if(!$cellElement->parentElement) { $tableRow->appendChild($cellElement); } + $this->appendDebugInfo($cellElement, "text"); } } @@ -519,11 +534,13 @@ private function initBinders(Document|Element $context):void { } $this->htmlAttributeBinder->setDependencies($this->listBinder, $this); + $this->htmlAttributeBinder->setDebugSource($this->debugSource); $this->elementBinder->setDependencies( $this->htmlAttributeBinder, $this->htmlAttributeCollection, $this->placeholderBinder, ); + $this->elementBinder->setDebugSource($this->debugSource); $this->listBinder->setDependencies( $this->elementBinder, $this->templateCollection, @@ -531,4 +548,31 @@ private function initBinders(Document|Element $context):void { $this, ); } + + private function appendDebugInfo(Element $element, string $bindProperty):void { + if(!$this->debugSource) { + return; + } + + $currentElement = $element; + while($currentElement) { + if($currentElement->hasAttribute("data-bind-debug")) { + break; + } + + $currentElement = $currentElement->parentElement; + } + if(!$currentElement) { + return; + } + + $entry = $bindProperty . "=" . $this->debugSource; + $current = trim($element->getAttribute("data-bind-debug") ?? ""); + $element->setAttribute( + "data-bind-debug", + $current === "" + ? $entry + : $current . "," . $entry + ); + } } diff --git a/test/phpunit/ComponentExpanderTest.php b/test/phpunit/ComponentExpanderTest.php index 9658e97..9f588e5 100644 --- a/test/phpunit/ComponentExpanderTest.php +++ b/test/phpunit/ComponentExpanderTest.php @@ -50,8 +50,7 @@ public function testExpand_empty():void { "empty-component" => "", ] ); - /** @noinspection HtmlRequiredLangAttribute */ - $document = new HTMLDocument(""); + $document = new HTMLDocument(HTMLPageContent::HTML_EMPTY_COMPONENT); $sut = new ComponentExpander($document, $partialContent); $expandedElements = $sut->expand(); self::assertCount(1, $expandedElements); diff --git a/test/phpunit/DocumentBinderTest.php b/test/phpunit/DocumentBinderTest.php index 1c3c140..4a6b499 100644 --- a/test/phpunit/DocumentBinderTest.php +++ b/test/phpunit/DocumentBinderTest.php @@ -164,6 +164,100 @@ public function testBindKeyValue():void { self::assertSame("This should bind", $document->querySelector("#container3 p span")->textContent); } + public function testBindKeyValue_dataBindDebugOnElement():void { + $document = new HTMLDocument(HTMLPageContent::HTML_BIND_DEBUG_SINGLE_NAME); + $sut = new DocumentBinder($document); + $sut->setDependencies(...$this->documentBinderDependencies($document)); + + $expectedDebug = $this->bindNameWithDebug($sut); + + $paragraph = $document->querySelector("p"); + self::assertSame("Cody", $paragraph->textContent); + self::assertSame("text=$expectedDebug", $paragraph->getAttribute("data-bind-debug")); + } + + public function testBindKeyValue_dataBindDebugOutsideProjectUsesAbsolutePath():void { + $tempFile = tempnam(sys_get_temp_dir(), "domtemplate-debug-"); + file_put_contents($tempFile, <<<'PHP' + bindKeyValue("name", "Cody"); + }; + PHP); + + try { + $document = new HTMLDocument(HTMLPageContent::HTML_BIND_DEBUG_SINGLE_NAME); + $sut = new DocumentBinder($document); + $sut->setDependencies(...$this->documentBinderDependencies($document)); + + $callback = require $tempFile; + $callback($sut); + + $debugAttribute = $document->querySelector("p")->getAttribute("data-bind-debug"); + self::assertMatchesRegularExpression( + "/^text=" . preg_quote(str_replace("\\", "/", $tempFile), "/") . ":\d+$/", + $debugAttribute + ); + } + finally { + unlink($tempFile); + } + } + + public function testBindKeyValue_dataBindDebugSkipsVendorCallerFrames():void { + $vendorFixtureDir = __DIR__ . "/../../vendor/.codex-fixtures"; + if(!is_dir($vendorFixtureDir)) { + mkdir($vendorFixtureDir, 0777, true); + } + + $wrapperFile = $vendorFixtureDir . "/bind-debug-wrapper.php"; + file_put_contents($wrapperFile, <<<'PHP' +bindKeyValue("name", "Cody"); +}; +PHP); + + try { + $document = new HTMLDocument(HTMLPageContent::HTML_BIND_DEBUG_SINGLE_NAME); + $sut = new DocumentBinder($document); + $sut->setDependencies(...$this->documentBinderDependencies($document)); + + $expectedLine = __LINE__ + 1; + (require $wrapperFile)($sut); + + self::assertSame( + "text=test/phpunit/DocumentBinderTest.php:$expectedLine", + $document->querySelector("p")->getAttribute("data-bind-debug") + ); + } + finally { + unlink($wrapperFile); + @rmdir($vendorFixtureDir); + } + } + + public function testBindData_dataBindDebugInheritedByDescendants():void { + $document = new HTMLDocument(HTMLPageContent::HTML_BIND_DEBUG_PROFILE); + $sut = new DocumentBinder($document); + $sut->setDependencies(...$this->documentBinderDependencies($document)); + + $expectedDebug = $this->bindProfileWithDebug($sut); + + $heading = $document->querySelector("#profile h1"); + $link = $document->querySelector("#profile a"); + $outside = $document->querySelector("aside span"); + self::assertSame("Cody", $heading->textContent); + self::assertSame("text=$expectedDebug", $heading->getAttribute("data-bind-debug")); + self::assertSame("mailto:cody@example.com", $link->getAttribute("href")); + self::assertSame("mailto:cody@example.com", $link->textContent); + self::assertSame( + "text=$expectedDebug,href=$expectedDebug", + $link->getAttribute("data-bind-debug") + ); + self::assertFalse($outside->hasAttribute("data-bind-debug")); + } + public function testBindKeyValue_null():void { $document = new HTMLDocument(HTMLPageContent::HTML_MULTIPLE_NESTED_ELEMENTS); $sut = new DocumentBinder($document); @@ -542,6 +636,21 @@ public function testBindTable_stringContext():void { self::assertSame("Staff Liason Officer", $table->rows[2]->cells[1]->textContent); } + public function testBindTable_dataBindDebug():void { + $document = new HTMLDocument(HTMLPageContent::HTML_BIND_DEBUG_TABLE); + $sut = new DocumentBinder($document); + $sut->setDependencies(...$this->documentBinderDependencies($document)); + + $expectedDebug = $this->bindTableWithDebug($sut); + $sut->cleanupDocument(); + + $cells = $document->querySelectorAll("tbody td"); + self::assertCount(2, $cells); + foreach($cells as $cell) { + self::assertSame("text=$expectedDebug", $cell->getAttribute("data-bind-debug")); + } + } + public function testBindTable_withNullData():void { $document = new HTMLDocument(HTMLPageContent::HTML_TABLES); $sut = new DocumentBinder($document); @@ -1090,6 +1199,40 @@ public function testCleanDatasets_dataBind():void { ); } + public function testCleanDatasets_dataBindDebugKeepsBoundMetadataAndRemovesEmptyMarkers():void { + $document = new HTMLDocument(HTMLPageContent::HTML_BIND_DEBUG_PROFILE_CLEANUP); + $sut = new DocumentBinder($document); + $sut->setDependencies(...$this->documentBinderDependencies($document)); + + $expectedDebug = $this->bindProfileCleanupDebug($sut); + $sut->cleanupDocument(); + + $section = $document->querySelector("#profile"); + $heading = $document->querySelector("#profile h1"); + $paragraph = $document->querySelector("#profile p"); + self::assertFalse($section->hasAttribute("data-bind-debug")); + self::assertSame("text=$expectedDebug", $heading->getAttribute("data-bind-debug")); + self::assertSame("text=$expectedDebug", $paragraph->getAttribute("data-bind-debug")); + self::assertFalse($heading->hasAttribute("data-bind:text")); + self::assertFalse($paragraph->hasAttribute("data-bind:text")); + } + + public function testBindList_dataBindDebug():void { + $document = new HTMLDocument(HTMLPageContent::HTML_BIND_DEBUG_LIST); + $sut = new DocumentBinder($document); + $sut->setDependencies(...$this->documentBinderDependencies($document)); + + $expectedDebug = $this->bindListWithDebug($sut); + $sut->cleanupDocument(); + + $listItems = $document->querySelectorAll("ul li"); + self::assertCount(2, $listItems); + self::assertSame("One", $listItems[0]->textContent); + self::assertSame("Two", $listItems[1]->textContent); + self::assertSame("text=$expectedDebug", $listItems[0]->getAttribute("data-bind-debug")); + self::assertSame("text=$expectedDebug", $listItems[1]->getAttribute("data-bind-debug")); + } + public function testCleanDatasets_dataTemplate():void { $document = new HTMLDocument(HTMLPageContent::HTML_LIST); $sut = new DocumentBinder($document); @@ -1398,6 +1541,29 @@ public function test_bindElementIsRemovedWhenNotBound():void { self::assertNull($errorDiv); } + public function testCleanupDocument_mixedDebugAndDataElementBehaviour():void { + $document = new HTMLDocument(HTMLPageContent::HTML_CLEANUP_MIXED_DEBUG); + $sut = new DocumentBinder($document); + $sut->setDependencies(...$this->documentBinderDependencies($document)); + + $expectedDebug = $this->bindCleanupMixedDebug($sut); + $sut->bindKeyValue("status", true); + $sut->cleanupDocument(); + + $scope = $document->getElementById("debug-scope"); + $name = $document->getElementById("name"); + $error = $document->getElementById("error"); + $status = $document->getElementById("status"); + self::assertFalse($scope->hasAttribute("data-bind-debug")); + self::assertSame("Cody", $name->textContent); + self::assertSame("text=$expectedDebug", $name->getAttribute("data-bind-debug")); + self::assertNull($error); + self::assertNotNull($status); + self::assertSame("Ready", $status->textContent); + self::assertFalse($status->hasAttribute("data-element")); + self::assertFalse($status->hasAttribute("data-bound")); + } + public function test_bindData_withList_dataBindList():void { $document = new HTMLDocument(HTMLPageContent::HTML_DATA_BIND_LIST); $sut = new DocumentBinder($document); @@ -1655,6 +1821,44 @@ public function testBindList_stringContext():void { $sut->bindList(["List", "for", "main component"]); } + public function testBindListCallback_stringContext():void { + $document = new HTMLDocument(HTMLPageContent::HTML_COMPONENT_WITH_ATTRIBUTE_NESTED); + $subComponent1 = $document->querySelector("#subcomponent-1"); + $subComponent2 = $document->querySelector("#subcomponent-2"); + + $listBinder = self::createMock(ListBinder::class); + $bindMatcher = self::exactly(2); + $listBinder->expects($bindMatcher) + ->method("bindListData") + ->willReturnCallback(function( + array $listData, + Element|Document $context, + ?string $templateName, + ?callable $callback, + )use($bindMatcher, $subComponent1, $subComponent2):int { + match($bindMatcher->numberOfInvocations()) { + 1 => self::assertEquals([["A"], $subComponent1], [$listData, $context]), + 2 => self::assertEquals([["B"], $subComponent2], [$listData, $context]), + }; + self::assertNotNull($callback); + return 0; + }); + + $sut = new DocumentBinder($document); + $sut->setDependencies( + self::createStub(ElementBinder::class), + self::createStub(PlaceholderBinder::class), + self::createStub(TableBinder::class), + $listBinder, + self::createStub(ListElementCollection::class), + self::createStub(BindableCache::class), + ); + + $callback = fn(Element $template, mixed $listItem, int|string $key):mixed => $listItem; + $sut->bindListCallback(["A"], $callback, "#subcomponent-1"); + $sut->bindListCallback(["B"], $callback, $subComponent2); + } + public function testBindValue_stringContext():void { $document = new HTMLDocument(HTMLPageContent::HTML_COMPONENT_WITH_ATTRIBUTE_NESTED); $documentElement = $document->documentElement; @@ -1710,6 +1914,55 @@ public function getCanReject():bool { self::assertTrue($buttonReject->hasAttribute("disabled")); } + private function bindNameWithDebug(DocumentBinder $sut):string { + $line = __LINE__ + 1; + $sut->bindKeyValue("name", "Cody"); + return "test/phpunit/DocumentBinderTest.php:$line"; + } + + private function bindProfileWithDebug(DocumentBinder $sut):string { + $line = __LINE__ + 1; + $sut->bindData([ + "username" => "Cody", + "email" => "mailto:cody@example.com", + "emailLink" => "mailto:cody@example.com", + ]); + return "test/phpunit/DocumentBinderTest.php:$line"; + } + + private function bindProfileCleanupDebug(DocumentBinder $sut):string { + $line = __LINE__ + 1; + $sut->bindData([ + "username" => "Cody", + "email" => "cody@example.com", + ]); + return "test/phpunit/DocumentBinderTest.php:$line"; + } + + private function bindListWithDebug(DocumentBinder $sut):string { + $line = __LINE__ + 1; + $sut->bindList(["One", "Two"]); + return "test/phpunit/DocumentBinderTest.php:$line"; + } + + private function bindTableWithDebug(DocumentBinder $sut):string { + $line = __LINE__ + 1; + $sut->bindTable([ + ["Name", "Role"], + ["Cody", "Maintainer"], + ], null, "tableData"); + return "test/phpunit/DocumentBinderTest.php:$line"; + } + + private function bindCleanupMixedDebug(DocumentBinder $sut):string { + $line = __LINE__ + 1; + $sut->bindData([ + "name" => "Cody", + "statusMessage" => "Ready", + ]); + return "test/phpunit/DocumentBinderTest.php:$line"; + } + private function documentBinderDependencies(HTMLDocument $document, mixed...$otherObjectList):array { $htmlAttributeBinder = new HTMLAttributeBinder(); $htmlAttributeCollection = new HTMLAttributeCollection(); diff --git a/test/phpunit/HTMLAttributeBinderTest.php b/test/phpunit/HTMLAttributeBinderTest.php index 93d7e2d..2e7fae2 100644 --- a/test/phpunit/HTMLAttributeBinderTest.php +++ b/test/phpunit/HTMLAttributeBinderTest.php @@ -145,6 +145,27 @@ public function testBind_modifierQuestion_withConditionalNoMatch():void { self::assertFalse($document->getElementById("size-l")->checked); } + public function testBind_modifierQuestion_withConditionalBooleanMatch():void { + $document = new HTMLDocument(HTMLPageContent::HTML_CONDITIONAL_BOOLEAN_CHECKBOX); + $sut = new HTMLAttributeBinder(); + $input = $document->getElementById("flag"); + $input->checked = false; + + $sut->bind("enabled", true, $input); + + self::assertTrue($input->checked); + } + + public function testBind_modifierQuestion_withConditionalIterableDoesNotMatch():void { + $document = new HTMLDocument(HTMLPageContent::HTML_CONDITIONAL_BOOLEAN_CHECKBOX); + $sut = new HTMLAttributeBinder(); + $input = $document->getElementById("flag"); + + $sut->bind("enabled", ["1"], $input); + + self::assertFalse($input->checked); + } + public function testBind_modifierQuestion_withConditionalMatch_attributeModifier():void { $document = new HTMLDocument( HTMLPageContent::HTML_RADIO_GROUP_CONDITIONAL_CHECKED_ATTRIBUTE_MODIFIER @@ -207,6 +228,20 @@ public function testBind_multipleAttributes():void { self::assertSame("value2", $outputElement->dataset->get("attr2")); } + public function testBind_multipleAttributes_withDebug():void { + $document = new HTMLDocument(HTMLPageContent::HTML_ATTRIBUTE_BIND_DEBUG); + $outputElement = $document->querySelector("output"); + $sut = new HTMLAttributeBinder(); + $sut->setDebugSource("app/ProfileController.php:42"); + $sut->bind("key1", "value1", $outputElement); + $sut->bind("key2", "value2", $outputElement); + + self::assertSame( + "data-attr1=app/ProfileController.php:42,data-attr2=app/ProfileController.php:42", + $outputElement->getAttribute("data-bind-debug") + ); + } + public function testExpandAttributes_atCharacter():void { $document = new HTMLDocument(HTMLPageContent::HTML_BASIC_FORM_WITH_AT_BINDER); $sut = new HTMLAttributeBinder(); @@ -220,9 +255,7 @@ public function testExpandAttributes_atCharacter():void { } public function testExpandAttributes_atCharacterDefaultsToName():void { - $document = new HTMLDocument( - "" - ); + $document = new HTMLDocument(HTMLPageContent::HTML_INPUT_VALUE_AT_DEFAULT_NAME); $input = $document->querySelector("input"); $sut = new HTMLAttributeBinder(); $sut->expandAttributes($input); @@ -230,9 +263,7 @@ public function testExpandAttributes_atCharacterDefaultsToName():void { } public function testExpandAttributes_listUsesTagNameWhenNoHyphen():void { - $document = new HTMLDocument( - "" - ); + $document = new HTMLDocument(HTMLPageContent::HTML_LIST_BIND_EMPTY_NAME); $list = $document->querySelector("ul"); $sut = new HTMLAttributeBinder(); $sut->expandAttributes($list); diff --git a/test/phpunit/ListElementCollectionTest.php b/test/phpunit/ListElementCollectionTest.php index 6f6fc7a..0d7db91 100644 --- a/test/phpunit/ListElementCollectionTest.php +++ b/test/phpunit/ListElementCollectionTest.php @@ -25,9 +25,7 @@ public function testGet_noName_noMatch():void { } public function testGet_noName_noMatchIncludesContextDescription():void { - $document = new HTMLDocument( - "
" - ); + $document = new HTMLDocument(HTMLPageContent::HTML_LIST_ELEMENT_COLLECTION_CONTEXT); $sut = new ListElementCollection($document); self::expectException(ListElementNotFoundInContextException::class); diff --git a/test/phpunit/TableBinderTest.php b/test/phpunit/TableBinderTest.php index 57fe556..0836f6d 100644 --- a/test/phpunit/TableBinderTest.php +++ b/test/phpunit/TableBinderTest.php @@ -1,6 +1,8 @@ tBodies[0]->rows); } + public function testBindTable_matchesAncestorBindKey():void { + $document = new HTMLDocument(HTMLPageContent::HTML_TABLE_ANCESTOR_BIND_KEY); + $sut = new TableBinder(); + $sut->setDependencies(...$this->tablebinderDependencies($document)); + + $sut->bindTableData([ + ["Name", "Role"], + ["Cody", "Maintainer"], + ], $document, "tableData"); + + $table = $document->getElementById("tbl"); + self::assertSame("Cody", $table->tBodies[0]->rows[0]->cells[0]->textContent); + self::assertSame("Maintainer", $table->tBodies[0]->rows[0]->cells[1]->textContent); + } + /** * Binding table data into a table that already has a element * will use the existing values to limit which columns are output. @@ -173,6 +190,44 @@ public function testBindTable_dataNormalised():void { self::assertSame("pollita@php.net", $row3->cells[2]->textContent); } + public function testBindTable_traversableData():void { + $document = new HTMLDocument(HTMLPageContent::HTML_TABLES); + $sut = new TableBinder(); + $sut->setDependencies(...$this->tablebinderDependencies($document)); + + $tableData = new ArrayIterator([ + ["Name", "Position"], + ["Alan Statham", "Head of Radiology"], + ["Sue White", "Staff Liason Officer"], + ]); + $sut->bindTableData($tableData, $document->getElementById("tbl1"), "tableData"); + + $table = $document->getElementById("tbl1"); + self::assertSame("Alan Statham", $table->tBodies[0]->rows[0]->cells[0]->textContent); + self::assertSame("Staff Liason Officer", $table->tBodies[0]->rows[1]->cells[1]->textContent); + } + + public function testBindTable_iteratorAggregateData():void { + $document = new HTMLDocument(HTMLPageContent::HTML_TABLES); + $sut = new TableBinder(); + $sut->setDependencies(...$this->tablebinderDependencies($document)); + + $tableData = new class implements IteratorAggregate { + public function getIterator():ArrayIterator { + return new ArrayIterator([ + ["Name", "Position"], + ["Alan Statham", "Head of Radiology"], + ["Sue White", "Staff Liason Officer"], + ]); + } + }; + $sut->bindTableData($tableData, $document->getElementById("tbl1"), "tableData"); + + $table = $document->getElementById("tbl1"); + self::assertSame("Alan Statham", $table->tBodies[0]->rows[0]->cells[0]->textContent); + self::assertSame("Staff Liason Officer", $table->tBodies[0]->rows[1]->cells[1]->textContent); + } + /** * A "double header" is a term I use to describe tables that have * header data in the first column going along the top, but also another @@ -670,6 +725,21 @@ public function testDetectTableStructureType_listRejectsMixedRowShapes():void { $sut->detectTableDataStructureType($data); } + public function testDetectTableStructureType_listRejectsMixedIterableAndScalarShapes():void { + $data = [ + ["Item", "Price"], + ["Washing machine", "69800"], + [ + "Television" => ["99800"], + "Stock Level" => ["7"], + ], + ]; + $sut = new TableBinder(); + + self::expectException(IncorrectTableDataFormat::class); + $sut->detectTableDataStructureType($data); + } + public function testBindTableData_noTableInitializesFallbackDependencies():void { $document = new HTMLDocument(HTMLPageContent::HTML_EMPTY); $sut = new TableBinder(); @@ -697,6 +767,23 @@ public function testBindTableData_initializesFallbackDependenciesWhenTableExists self::assertSame("Staff Liason Officer", $table->tBodies[0]->rows[1]->cells[1]->textContent); } + public function testBindTableData_debugAddsMetadataToGeneratedCells():void { + $document = new HTMLDocument(HTMLPageContent::HTML_BIND_DEBUG_GENERATED_TABLE); + $sut = new TableBinder(); + $sut->setDependencies(...$this->tablebinderDependencies($document)); + $sut->setDebugSource("app/Controller.php:10"); + + $sut->bindTableData([ + ["Name", "Role"], + ["Cody", "Maintainer"], + ], $document, "tableData"); + + $headerCell = $document->querySelector("thead td"); + $bodyCell = $document->querySelector("tbody td"); + self::assertSame("text=app/Controller.php:10", $headerCell->getAttribute("data-bind-debug")); + self::assertSame("text=app/Controller.php:10", $bodyCell->getAttribute("data-bind-debug")); + } + private function tablebinderDependencies(HTMLDocument $document):array { $htmlAttributeBinder = new HTMLAttributeBinder(); $htmlAttributeCollection = new HTMLAttributeCollection(); diff --git a/test/phpunit/TestHelper/HTMLPageContent.php b/test/phpunit/TestHelper/HTMLPageContent.php index e9b833e..e775dfe 100644 --- a/test/phpunit/TestHelper/HTMLPageContent.php +++ b/test/phpunit/TestHelper/HTMLPageContent.php @@ -91,6 +91,77 @@ class HTMLPageContent { HTML; + const HTML_EMPTY_COMPONENT = << +HTML; + + const HTML_LIST_ELEMENT_COLLECTION_CONTEXT = <<
+HTML; + + const HTML_TABLE_ANCESTOR_BIND_KEY = <<
+HTML; + + const HTML_CONDITIONAL_BOOLEAN_CHECKBOX = << +HTML; + + const HTML_ATTRIBUTE_BIND_DEBUG = <<
Nothing is bound
+HTML; + + const HTML_INPUT_VALUE_AT_DEFAULT_NAME = << +HTML; + + const HTML_LIST_BIND_EMPTY_NAME = <<
    +HTML; + + const HTML_BIND_DEBUG_SINGLE_NAME = <<

    Test

    +HTML; + + const HTML_BIND_DEBUG_PROFILE = << +
    +

    Guest

    +

    Contact: guest@example.com

    +
    + +HTML; + + const HTML_BIND_DEBUG_PROFILE_CLEANUP = << +
    +

    Guest

    +

    guest@example.com

    +
    +HTML; + + const HTML_BIND_DEBUG_LIST = << +
    +
      +
    • Template
    • +
    +
    +HTML; + + const HTML_BIND_DEBUG_TABLE = << +
    +
    +
    +HTML; + + const HTML_BIND_DEBUG_GENERATED_TABLE = <<
    +HTML; + const HTML_DIFFERENT_BIND_PROPERTIES = << @@ -1184,6 +1255,15 @@ class HTMLPageContent { +HTML; + + const HTML_CLEANUP_MIXED_DEBUG = << +
    +

    Guest

    +
    Error message
    +
    Pending
    +
    HTML; const HTML_DATA_BIND_LIST = <<