2 Commits

Author SHA1 Message Date
ea2ccef5de Fix CI build: install Composer deps before npm build (v1.1.1)
All checks were successful
Create Release Package / PHP Lint (push) Successful in 1m13s
Create Release Package / PHPUnit Tests (push) Successful in 1m8s
Create Release Package / Build Release (push) Successful in 1m58s
The prebuild hook runs phpunit via composer exec, but Composer
dependencies were not installed until after npm run build. Moved
composer install (with dev) before the build step, then reinstall
with --no-dev for the release package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:14:13 +01:00
e607382e11 Add PHPUnit test suite with 64 unit tests (v1.1.1)
Some checks failed
Create Release Package / PHP Lint (push) Successful in 1m6s
Create Release Package / PHPUnit Tests (push) Successful in 1m4s
Create Release Package / Build Release (push) Failing after 1m13s
PHPUnit 11 + Brain\Monkey for WordPress function mocking. Tests cover
BlockRenderer (28), WidgetRenderer (9), NavWalker (14), and
TemplateController (12). Includes functional WP_HTML_Tag_Processor stub,
CI test job between lint and build-release, prebuild hook gating npm
build on passing tests, and release package exclusions for test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:08:34 +01:00
18 changed files with 3242 additions and 17 deletions

View File

@@ -24,10 +24,31 @@ jobs:
run: |
find . -name "*.php" -not -path "./vendor/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 php -l
test:
name: PHPUnit Tests
runs-on: ubuntu-latest
needs: [lint]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, xml, dom
tools: composer:v2
- name: Install Composer dependencies
run: composer install --no-interaction
- name: Run PHPUnit
run: composer exec -- phpunit
build-release:
name: Build Release
runs-on: ubuntu-latest
needs: [lint]
needs: [test]
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -54,16 +75,19 @@ jobs:
- name: Install Node dependencies
run: npm install
- name: Build assets
run: npm run build
- name: Validate composer.json
run: composer validate --no-check-lock --no-check-all
- name: Install Composer dependencies (production)
- name: Install Composer dependencies (with dev for prebuild tests)
run: |
composer config platform.php 8.3.0
composer install --no-dev --optimize-autoloader --no-interaction
composer install --no-interaction
- name: Build assets
run: npm run build
- name: Reinstall Composer dependencies (production only)
run: composer install --no-dev --optimize-autoloader --no-interaction
- name: Install gettext
run: apt-get update && apt-get install -y gettext
@@ -120,6 +144,9 @@ jobs:
-x "${THEME_NAME}/*.log" \
-x "${THEME_NAME}/*.po~" \
-x "${THEME_NAME}/*.bak" \
-x "${THEME_NAME}/tests/*" \
-x "${THEME_NAME}/phpunit.xml.dist" \
-x "${THEME_NAME}/.phpunit.cache/*" \
-x "${THEME_NAME}/views/.gitkeep" \
-x "${THEME_NAME}/assets/images/.gitkeep" \
-x "*.DS_Store"
@@ -187,6 +214,14 @@ jobs:
echo "src/ excluded: OK"
fi
# Verify tests excluded
if unzip -l "releases/${THEME_NAME}-${VERSION}.zip" | grep -q "${THEME_NAME}/tests/"; then
echo "WARNING: tests/ directory should be excluded"
exit 1
else
echo "tests/ excluded: OK"
fi
- name: Extract changelog for release notes
id: changelog
run: |

3
.gitignore vendored
View File

@@ -28,5 +28,8 @@ npm-debug.log
# Claude local settings
.claude/settings.local.json
# PHPUnit cache
.phpunit.cache/
# Build artifacts (releases directory)
releases/

View File

@@ -2,6 +2,16 @@
All notable changes to this project will be documented in this file.
## [1.1.1] - 2026-02-28
### Added
- **PHPUnit test suite**: 64 unit tests covering `BlockRenderer`, `WidgetRenderer`, `NavWalker`, and `TemplateController` classes with 107 assertions. Uses PHPUnit 11 and Brain\Monkey for WordPress function mocking.
- **Test infrastructure**: `WP_HTML_Tag_Processor` functional stub using DOMDocument for testing block renderer HTML manipulation outside WordPress. Empty stubs for `WP_Block` and `WP_Widget` type hints.
- **Build pipeline integration**: Tests run automatically before every `npm run build` via `prebuild` hook (`composer exec -- phpunit`).
- **CI test job**: New PHPUnit test step in Gitea CI workflow between lint and build-release. Tests must pass before release packages are built.
- **Release package exclusions**: `tests/`, `phpunit.xml.dist`, and `.phpunit.cache/` excluded from release ZIP packages with verification step.
## [1.1.0] - 2026-02-28
### Added

View File

@@ -34,7 +34,7 @@ This project is proudly **"vibe-coded"** using Claude.AI - the entire codebase w
**Note for AI Assistants:** Clean this section after the specific features are done or new releases are made. Effective changes are tracked in `CHANGELOG.md`. Do not add completed versions here - document them in the Session History section at the end of this file.
Current version is **v1.1.0**. See `PLAN.md` for details.
Current version is **v1.1.1**. See `PLAN.md` for details.
## Technical Stack
@@ -234,6 +234,57 @@ Build steps (in order):
## Session History
### Session 20 — v1.1.1 PHPUnit Test Suite (2026-02-28)
**Completed:** PHPUnit test suite with 64 unit tests and 107 assertions, CI integration, and build pipeline gating.
**What was built:**
- **PHPUnit 11 + Brain\Monkey 2.7**: Added as `require-dev` with PSR-4 `autoload-dev` mapping (`WPBootstrap\Tests\``tests/`).
- **WP_HTML_Tag_Processor stub** (`tests/Stubs/WpHtmlTagProcessor.php`): Functional DOMDocument-based replacement supporting `next_tag()` (by tag name or class_name), `add_class()` (idempotent), and `get_updated_html()`. Uses full HTML document wrapping for reliable body extraction.
- **BlockRendererTest** (28 tests): All 8 render methods — table classes, striped tables, button variants (default/bg/outline/gradient/unknown), button group flex, img-fluid, search input-group, blockquote+cite, pullquote, list-group, empty content returns.
- **WidgetRendererTest** (9 tests): Card structure, title heading, widget ID, type class extraction, generic filtering, h2→h4 regex, multiple h2 elements, empty content.
- **NavWalkerTest** (14 tests): Tree building (empty, single, flat, nested, multi-parent, orphans), node structure, classes, index reset, active detection (current-menu-item, ancestor, is_page, is_category, inactive).
- **TemplateControllerTest** (12 tests): Template resolution via ReflectionMethod for all page types (404, search, post default/full-width, page default/landing/full-width/hero/sidebar, archive, home, fallback).
- **Build pipeline**: `npm run test` and `prebuild` hook gate `npm run build` on passing tests.
- **CI workflow**: New `test` job between `lint` and `build-release` with PHP 8.3 + Composer.
- **Release exclusions**: `tests/`, `phpunit.xml.dist`, `.phpunit.cache/*` excluded from ZIP with verification step.
**Architecture decisions:**
- **Brain\Monkey over full WordPress bootstrap**: WordPress function mocking via `Functions\when()->justReturn()` and `Functions\when()->alias()` enables fast, isolated unit tests without a running WordPress installation.
- **DOMDocument stub over regex**: `WP_HTML_Tag_Processor` stub uses `DOMDocument`/`DOMXPath` for reliable HTML parsing. Full document wrapping (`<!DOCTYPE><html><body>...`) required because `LIBXML_HTML_NOIMPLIED` prevents `<body>` creation in PHP 8.4, breaking `getElementsByTagName('body')`.
- **`is_admin()=true` constructor bypass**: BlockRenderer/WidgetRenderer constructors register WordPress filters. Mocking `is_admin()` to return true causes early exit, enabling direct method testing.
- **ReflectionMethod for private methods**: `TemplateController::resolveTemplate()` is private. Testing via reflection avoids refactoring production code for testability.
- **ContextBuilder and TwigService skipped**: Too many WordPress dependencies for practical unit testing — better suited for integration tests.
**Key findings:**
- `LIBXML_HTML_NOIMPLIED` with `DOMDocument::loadHTML()` on PHP 8.4 does not create a `<body>` element, causing `getElementsByTagName('body')->item(0)` to return null. Solution: wrap input in full HTML document structure.
- Brain\Monkey's `Functions\when()->alias()` supports argument-dependent returns for functions like `is_singular()` that behave differently based on post type argument.
- `spl_object_id()` used in the stub's visited-node tracking enables sequential `next_tag()` advancement matching WordPress's forward-only API.
**Files created:**
- `phpunit.xml.dist` — PHPUnit configuration
- `tests/bootstrap.php` — Autoloader + stub loading
- `tests/Stubs/WpHtmlTagProcessor.php` — Functional DOMDocument-based stub
- `tests/Stubs/WpBlock.php` — Empty class stub
- `tests/Stubs/WpWidget.php` — Empty class stub
- `tests/Unit/Block/BlockRendererTest.php` — 28 tests
- `tests/Unit/Block/WidgetRendererTest.php` — 9 tests
- `tests/Unit/Template/NavWalkerTest.php` — 14 tests
- `tests/Unit/Template/TemplateControllerTest.php` — 12 tests
**Files modified:**
- `composer.json``require-dev`, `autoload-dev`
- `package.json``test`, `prebuild` scripts
- `.gitea/workflows/release.yml` — test job, exclusions, verification
- `.gitignore``.phpunit.cache/`
- `style.css` — version bump to 1.1.1
- `CHANGELOG.md`, `README.md`, `CLAUDE.md` — documentation
### Session 19 — v1.1.0 Block Renderer, Widget Renderer & Sidebar Post Layout (2026-02-28)
**Completed:** Bootstrap 5 class injection for core blocks, sidebar widget card wrappers, widget SCSS styling, and sidebar-default post template.

View File

@@ -62,20 +62,41 @@ Activate the theme in **Appearance > Themes** in the WordPress admin.
| Command | Description |
| --- | --- |
| `npm run build` | Full production build (copy JS, compile SCSS, minify CSS) |
| `npm run build` | Full production build (runs tests, copies JS, compiles SCSS, minifies CSS) |
| `npm run test` | Run PHPUnit test suite |
| `npm run dev` | Watch SCSS files and recompile on changes |
| `npm run scss` | Compile SCSS only |
| `npm run postcss` | Minify CSS with Autoprefixer and cssnano |
| `composer install` | Install PHP dependencies (Twig) |
### Testing
The theme includes a PHPUnit test suite with 64 unit tests and 107 assertions covering the core PHP classes:
- **BlockRenderer** -- All 8 render methods (table, button, buttons, image, search, quote, pullquote, list)
- **WidgetRenderer** -- Card wrapping, heading downgrade, class extraction
- **NavWalker** -- Tree building, active item detection, orphan handling
- **TemplateController** -- Template resolution for all page types
Tests use [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) for WordPress function mocking and a functional `WP_HTML_Tag_Processor` stub.
```bash
# Run tests
composer exec -- phpunit
# Tests also run automatically before every build
npm run build
```
### Build Pipeline
1. `copy:js` -- Copy Bootstrap JS bundle from `node_modules` to `assets/js/`
2. `copy:theme-js` -- Copy theme JS (dark-mode.js) from `src/js/` to `assets/js/`
3. `copy:icons` -- Copy Bootstrap Icons font files (`.woff`, `.woff2`) to `assets/fonts/`
4. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
5. `scss:rtl` -- Compile RTL stylesheet (`assets/css/rtl.css`)
6. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css`
1. `test` -- Run PHPUnit test suite (automatic via `prebuild` hook)
2. `copy:js` -- Copy Bootstrap JS bundle from `node_modules` to `assets/js/`
3. `copy:theme-js` -- Copy theme JS (dark-mode.js) from `src/js/` to `assets/js/`
4. `copy:icons` -- Copy Bootstrap Icons font files (`.woff`, `.woff2`) to `assets/fonts/`
5. `scss` -- Compile SCSS (`src/scss/`) to CSS (`assets/css/`)
6. `scss:rtl` -- Compile RTL stylesheet (`assets/css/rtl.css`)
7. `postcss` -- Autoprefixer + cssnano minification to `assets/css/style.min.css`
## Architecture
@@ -140,6 +161,9 @@ wp-bootstrap/
| +-- js/ Source JavaScript
| +-- scss/ Source SCSS
+-- styles/ Style variations (JSON)
+-- tests/
| +-- Stubs/ WordPress class stubs for testing
| +-- Unit/ PHPUnit test cases
+-- templates/ FSE templates (HTML)
+-- views/ Twig templates (Bootstrap 5 HTML)
| +-- base.html.twig
@@ -158,6 +182,7 @@ wp-bootstrap/
- **Bootstrap 5.3+** CSS & JS (served locally)
- **Dart Sass** for SCSS compilation
- **PostCSS** with Autoprefixer and cssnano
- **PHPUnit 11** with Brain\Monkey for WordPress function mocking
## License

View File

@@ -14,11 +14,20 @@
"php": ">=8.3",
"twig/twig": "^3.0"
},
"require-dev": {
"brain/monkey": "^2.6",
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"WPBootstrap\\": "inc/"
}
},
"autoload-dev": {
"psr-4": {
"WPBootstrap\\Tests\\": "tests/"
}
},
"config": {
"optimize-autoloader": true,
"sort-packages": true

2038
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,6 +33,8 @@
"copy:js": "copyfiles -f node_modules/bootstrap/dist/js/bootstrap.bundle.min.js node_modules/bootstrap/dist/js/bootstrap.bundle.min.js.map assets/js/",
"copy:theme-js": "copyfiles -f src/js/dark-mode.js assets/js/",
"copy:icons": "copyfiles -f node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff node_modules/bootstrap-icons/font/fonts/bootstrap-icons.woff2 assets/fonts/",
"test": "composer exec -- phpunit",
"prebuild": "npm run test",
"build": "npm run copy:js && npm run copy:theme-js && npm run copy:icons && npm run scss && npm run scss:rtl && npm run postcss",
"watch": "npm run copy:js && npm run scss:watch",
"dev": "npm run watch"

21
phpunit.xml.dist Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="tests/bootstrap.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnRisky="true"
failOnWarning="true">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>inc</directory>
</include>
</source>
</phpunit>

View File

@@ -7,7 +7,7 @@ Description: A modern WordPress Block Theme built from scratch with Bootstrap 5.
Requires at least: 6.7
Tested up to: 6.7
Requires PHP: 8.3
Version: 1.1.0
Version: 1.1.1
License: GNU General Public License v2 or later
License URI: http://www.gnu.org/licenses/gpl-2.0.html
Text Domain: wp-bootstrap

10
tests/Stubs/WpBlock.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
/**
* Stub for WordPress WP_Block class.
*
* Used as a type hint in BlockRenderer handler methods.
* Only needs to exist — no functionality required.
*/
class WP_Block
{
}

View File

@@ -0,0 +1,135 @@
<?php
/**
* Functional stub for WordPress WP_HTML_Tag_Processor.
*
* Uses DOMDocument/DOMXPath to provide the subset of functionality
* used by BlockRenderer: next_tag(), add_class(), get_updated_html().
*
* This is NOT a complete implementation — only the methods and query
* modes actually used in the theme are supported.
*/
class WP_HTML_Tag_Processor
{
private string $html;
private \DOMDocument $doc;
private \DOMXPath $xpath;
private ?\DOMElement $current = null;
/** @var int[] Object IDs of already-visited elements. */
private array $visited = [];
public function __construct(string $html)
{
$this->html = $html;
$this->doc = new \DOMDocument();
$this->doc->encoding = 'UTF-8';
// Wrap in a full HTML document so <body> is always present.
// This ensures get_updated_html() can reliably extract content.
@$this->doc->loadHTML(
'<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>'
. $html
. '</body></html>',
LIBXML_HTML_NODEFDTD
);
$this->xpath = new \DOMXPath($this->doc);
}
/**
* Advance to the next matching tag.
*
* @param string|array|null $query Tag name (string) or ['class_name' => '...'] (array).
*/
public function next_tag($query = null): bool
{
$tagName = null;
$className = null;
if (is_string($query)) {
$tagName = strtolower($query);
} elseif (is_array($query)) {
$tagName = isset($query['tag_name']) ? strtolower($query['tag_name']) : null;
$className = $query['class_name'] ?? null;
}
// Build XPath — search within <body> only.
$body = $this->doc->getElementsByTagName('body')->item(0);
if (!$body) {
return false;
}
$xpathExpr = './/*';
$conditions = [];
if ($tagName !== null) {
$conditions[] = sprintf(
"translate(local-name(), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') = '%s'",
$tagName
);
}
if ($className !== null) {
$conditions[] = sprintf(
"contains(concat(' ', normalize-space(@class), ' '), ' %s ')",
$className
);
}
if ($conditions) {
$xpathExpr .= '[' . implode(' and ', $conditions) . ']';
}
$nodes = $this->xpath->query($xpathExpr, $body);
foreach ($nodes as $node) {
$oid = spl_object_id($node);
if (!in_array($oid, $this->visited, true)) {
$this->current = $node;
$this->visited[] = $oid;
return true;
}
}
$this->current = null;
return false;
}
/**
* Add a CSS class to the current tag (idempotent).
*/
public function add_class(string $className): bool
{
if ($this->current === null) {
return false;
}
$existing = $this->current->getAttribute('class');
$classes = $existing ? array_filter(explode(' ', $existing)) : [];
if (!in_array($className, $classes, true)) {
$classes[] = $className;
}
$this->current->setAttribute('class', implode(' ', $classes));
return true;
}
/**
* Return the modified HTML.
*/
public function get_updated_html(): string
{
$body = $this->doc->getElementsByTagName('body')->item(0);
if (!$body) {
return $this->html;
}
$html = '';
foreach ($body->childNodes as $child) {
$html .= $this->doc->saveHTML($child);
}
return $html;
}
}

10
tests/Stubs/WpWidget.php Normal file
View File

@@ -0,0 +1,10 @@
<?php
/**
* Stub for WordPress WP_Widget class.
*
* Used as a type hint in WidgetRenderer::processBlockWidgetContent().
* Only needs to exist — no functionality required.
*/
class WP_Widget
{
}

View File

@@ -0,0 +1,323 @@
<?php
declare(strict_types=1);
namespace WPBootstrap\Tests\Unit\Block;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WPBootstrap\Block\BlockRenderer;
class BlockRendererTest extends TestCase
{
private BlockRenderer $renderer;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Return true so the constructor skips add_filter() registration.
Functions\when('is_admin')->justReturn(true);
Functions\when('wp_doing_ajax')->justReturn(false);
$this->renderer = new BlockRenderer();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
// ── core/table ──────────────────────────────────────────────
public function testRenderTableAddsTableClass(): void
{
$html = '<figure class="wp-block-table"><table><thead><tr><th>A</th></tr></thead><tbody><tr><td>1</td></tr></tbody></table></figure>';
$block = ['attrs' => []];
$result = $this->renderer->renderTable($html, $block);
$this->assertStringContainsString('table', $this->classesOf('table', $result));
}
public function testRenderTableWithStripesAddsStripedClass(): void
{
$html = '<figure class="wp-block-table is-style-stripes"><table><tr><td>1</td></tr></table></figure>';
$block = ['attrs' => ['className' => 'is-style-stripes']];
$result = $this->renderer->renderTable($html, $block);
$classes = $this->classesOf('table', $result);
$this->assertStringContainsString('table', $classes);
$this->assertStringContainsString('table-striped', $classes);
}
public function testRenderTableEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderTable('', []));
}
public function testRenderTableWithoutTableTagReturnsOriginal(): void
{
$html = '<p>No table here</p>';
$this->assertSame($html, $this->renderer->renderTable($html, []));
}
// ── core/button ─────────────────────────────────────────────
public function testRenderButtonAddsBtnPrimaryByDefault(): void
{
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => []];
$result = $this->renderer->renderButton($html, $block);
$classes = $this->classesOf('a', $result);
$this->assertStringContainsString('btn', $classes);
$this->assertStringContainsString('btn-primary', $classes);
}
public function testRenderButtonWithBackgroundColor(): void
{
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['backgroundColor' => 'danger']];
$result = $this->renderer->renderButton($html, $block);
$classes = $this->classesOf('a', $result);
$this->assertStringContainsString('btn', $classes);
$this->assertStringContainsString('btn-danger', $classes);
}
public function testRenderButtonOutlineStyle(): void
{
$html = '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['className' => 'is-style-outline', 'textColor' => 'success']];
$result = $this->renderer->renderButton($html, $block);
$classes = $this->classesOf('a', $result);
$this->assertStringContainsString('btn', $classes);
$this->assertStringContainsString('btn-outline-success', $classes);
}
public function testRenderButtonOutlineDefaultsToPrimary(): void
{
$html = '<div class="wp-block-button is-style-outline"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['className' => 'is-style-outline']];
$result = $this->renderer->renderButton($html, $block);
$this->assertStringContainsString('btn-outline-primary', $this->classesOf('a', $result));
}
public function testRenderButtonWithGradientOnlyAddsBtnBase(): void
{
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['gradient' => 'vivid-cyan-blue']];
$result = $this->renderer->renderButton($html, $block);
$classes = $this->classesOf('a', $result);
$this->assertStringContainsString('btn', $classes);
$this->assertStringNotContainsString('btn-primary', $classes);
}
public function testRenderButtonWithUnknownColorDefaultsToPrimary(): void
{
$html = '<div class="wp-block-button"><a class="wp-block-button__link" href="#">Click</a></div>';
$block = ['attrs' => ['backgroundColor' => 'custom-teal']];
$result = $this->renderer->renderButton($html, $block);
$this->assertStringContainsString('btn-primary', $this->classesOf('a', $result));
}
public function testRenderButtonEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderButton('', []));
}
// ── core/buttons ────────────────────────────────────────────
public function testRenderButtonsAddsFlexClasses(): void
{
$html = '<div class="wp-block-buttons"><div class="wp-block-button"><a class="wp-block-button__link" href="#">A</a></div></div>';
$block = ['attrs' => []];
$result = $this->renderer->renderButtons($html, $block);
$classes = $this->classesOfFirst('.wp-block-buttons', $result);
$this->assertStringContainsString('d-flex', $classes);
$this->assertStringContainsString('flex-wrap', $classes);
$this->assertStringContainsString('gap-2', $classes);
}
public function testRenderButtonsEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderButtons('', []));
}
// ── core/image ──────────────────────────────────────────────
public function testRenderImageAddsImgFluid(): void
{
$html = '<figure class="wp-block-image"><img src="photo.jpg" alt="Photo"></figure>';
$block = ['attrs' => []];
$result = $this->renderer->renderImage($html, $block);
$this->assertStringContainsString('img-fluid', $this->classesOf('img', $result));
}
public function testRenderImageEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderImage('', []));
}
// ── core/search ─────────────────────────────────────────────
public function testRenderSearchAddsBootstrapClasses(): void
{
$html = '<form class="wp-block-search" role="search">'
. '<div class="wp-block-search__inside-wrapper">'
. '<input class="wp-block-search__input" type="search" placeholder="Search">'
. '<button class="wp-block-search__button" type="submit">Search</button>'
. '</div></form>';
$block = ['attrs' => []];
$result = $this->renderer->renderSearch($html, $block);
$this->assertStringContainsString('input-group', $result);
$this->assertStringContainsString('form-control', $result);
$this->assertStringContainsString('btn-primary', $result);
}
public function testRenderSearchEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderSearch('', []));
}
// ── core/quote ──────────────────────────────────────────────
public function testRenderQuoteAddsBlockquoteClass(): void
{
$html = '<blockquote class="wp-block-quote"><p>Quote text</p></blockquote>';
$block = ['attrs' => []];
$result = $this->renderer->renderQuote($html, $block);
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
}
public function testRenderQuoteWithCiteAddsFooterClass(): void
{
$html = '<blockquote class="wp-block-quote"><p>Quote</p><cite>Author</cite></blockquote>';
$block = ['attrs' => []];
$result = $this->renderer->renderQuote($html, $block);
$this->assertStringContainsString('blockquote-footer', $result);
}
public function testRenderQuoteWithoutCite(): void
{
$html = '<blockquote class="wp-block-quote"><p>Quote only</p></blockquote>';
$block = ['attrs' => []];
$result = $this->renderer->renderQuote($html, $block);
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
$this->assertStringNotContainsString('blockquote-footer', $result);
}
public function testRenderQuoteEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderQuote('', []));
}
// ── core/pullquote ──────────────────────────────────────────
public function testRenderPullquoteAddsBlockquoteClass(): void
{
$html = '<figure class="wp-block-pullquote"><blockquote><p>Pull</p></blockquote></figure>';
$block = ['attrs' => []];
$result = $this->renderer->renderPullquote($html, $block);
$this->assertStringContainsString('blockquote', $this->classesOf('blockquote', $result));
}
public function testRenderPullquoteWithCiteAddsFooterClass(): void
{
$html = '<figure class="wp-block-pullquote"><blockquote><p>Pull</p><cite>Source</cite></blockquote></figure>';
$block = ['attrs' => []];
$result = $this->renderer->renderPullquote($html, $block);
$this->assertStringContainsString('blockquote-footer', $result);
}
public function testRenderPullquoteEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderPullquote('', []));
}
// ── core/list ───────────────────────────────────────────────
public function testRenderListGroupAddsClasses(): void
{
$html = '<ul class="is-style-list-group"><li>A</li><li>B</li><li>C</li></ul>';
$block = ['attrs' => ['className' => 'is-style-list-group']];
$result = $this->renderer->renderList($html, $block);
$this->assertStringContainsString('list-group', $this->classesOf('ul', $result));
$this->assertStringContainsString('list-group-item', $result);
}
public function testRenderListGroupOrderedList(): void
{
$html = '<ol class="is-style-list-group"><li>1</li><li>2</li></ol>';
$block = ['attrs' => ['className' => 'is-style-list-group', 'ordered' => true]];
$result = $this->renderer->renderList($html, $block);
$this->assertStringContainsString('list-group', $this->classesOf('ol', $result));
}
public function testRenderListWithoutGroupStyleReturnsUnchanged(): void
{
$html = '<ul><li>A</li></ul>';
$block = ['attrs' => []];
$result = $this->renderer->renderList($html, $block);
$this->assertStringNotContainsString('list-group', $result);
}
public function testRenderListEmptyContentReturnsEmpty(): void
{
$this->assertSame('', $this->renderer->renderList('', []));
}
// ── helpers ─────────────────────────────────────────────────
/**
* Extract the class attribute value of the first matching tag.
*/
private function classesOf(string $tagName, string $html): string
{
$doc = new \DOMDocument();
@$doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NODEFDTD);
$elements = $doc->getElementsByTagName($tagName);
return $elements->length > 0 ? ($elements->item(0)->getAttribute('class') ?? '') : '';
}
/**
* Extract classes from the first element matching a CSS selector-style class.
*/
private function classesOfFirst(string $selector, string $html): string
{
$className = ltrim($selector, '.');
$doc = new \DOMDocument();
@$doc->loadHTML('<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body>' . $html . '</body></html>', LIBXML_HTML_NODEFDTD);
$xpath = new \DOMXPath($doc);
$body = $doc->getElementsByTagName('body')->item(0);
if (!$body) {
return '';
}
$nodes = $xpath->query(sprintf(
".//*[contains(concat(' ', normalize-space(@class), ' '), ' %s ')]",
$className
), $body);
return $nodes->length > 0 ? ($nodes->item(0)->getAttribute('class') ?? '') : '';
}
}

View File

@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
namespace WPBootstrap\Tests\Unit\Block;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WPBootstrap\Block\WidgetRenderer;
class WidgetRendererTest extends TestCase
{
private WidgetRenderer $renderer;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Skip constructor filter registration.
Functions\when('is_admin')->justReturn(true);
Functions\when('wp_doing_ajax')->justReturn(false);
$this->renderer = new WidgetRenderer();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
// ── wrapWidgetInCard ────────────────────────────────────────
public function testWrapWidgetInCardSetsCardStructure(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('block-2', 'widget mb-4 widget_block');
$result = $this->renderer->wrapWidgetInCard($params);
$this->assertStringContainsString('card', $result[0]['before_widget']);
$this->assertStringContainsString('card-body', $result[0]['before_widget']);
$this->assertSame('</div></div>', $result[0]['after_widget']);
}
public function testWrapWidgetInCardSetsCardTitle(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('block-3', 'widget mb-4 widget_block');
$result = $this->renderer->wrapWidgetInCard($params);
$this->assertStringContainsString('<h4', $result[0]['before_title']);
$this->assertStringContainsString('card-title', $result[0]['before_title']);
$this->assertSame('</h4>', $result[0]['after_title']);
}
public function testWrapWidgetInCardPreservesWidgetId(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('search-1', 'widget mb-4 widget_search');
$result = $this->renderer->wrapWidgetInCard($params);
$this->assertStringContainsString('id="search-1"', $result[0]['before_widget']);
}
public function testWrapWidgetInCardPreservesWidgetTypeClasses(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('block-5', 'widget mb-4 widget_block wp-block-heading');
$result = $this->renderer->wrapWidgetInCard($params);
// widget_block and wp-block-heading are kept; widget and mb-4 are removed.
$this->assertStringContainsString('widget_block', $result[0]['before_widget']);
$this->assertStringContainsString('wp-block-heading', $result[0]['before_widget']);
}
public function testWrapWidgetInCardFiltersGenericClasses(): void
{
Functions\when('esc_attr')->returnArg();
$params = $this->makeParams('block-2', 'widget mb-4 widget_block');
$result = $this->renderer->wrapWidgetInCard($params);
// The card wrapper adds its own "widget" class, but the extracted
// classes should not include the generic "widget" or "mb-4".
// Count occurrences: "card mb-3 widget " + "widget_block" should have exactly one "widget".
preg_match_all('/\bwidget\b/', $result[0]['before_widget'], $matches);
$this->assertCount(1, $matches[0], 'Generic "widget" class should appear once (from card wrapper), not duplicated');
}
public function testWrapWidgetInCardWithNoExistingClasses(): void
{
Functions\when('esc_attr')->returnArg();
$params = [
[
'widget_id' => 'text-1',
'before_widget' => '<div id="text-1">',
'after_widget' => '</div>',
'before_title' => '<h3>',
'after_title' => '</h3>',
],
];
$result = $this->renderer->wrapWidgetInCard($params);
$this->assertStringContainsString('card', $result[0]['before_widget']);
$this->assertStringContainsString('id="text-1"', $result[0]['before_widget']);
}
// ── processBlockWidgetContent ───────────────────────────────
public function testProcessBlockWidgetContentReplacesH2WithH4(): void
{
$content = '<h2 class="wp-block-heading">Categories</h2><ul><li>Cat A</li></ul>';
$widget = new \WP_Widget();
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
$this->assertStringContainsString('<h4 class="wp-block-heading">', $result);
$this->assertStringContainsString('</h4>', $result);
$this->assertStringNotContainsString('<h2', $result);
$this->assertStringNotContainsString('</h2>', $result);
}
public function testProcessBlockWidgetContentPreservesOtherH2(): void
{
$content = '<h2 class="custom-heading">Keep me</h2>';
$widget = new \WP_Widget();
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
// h2 without wp-block-heading class should remain.
$this->assertStringContainsString('<h2 class="custom-heading">', $result);
}
public function testProcessBlockWidgetContentEmptyReturnsEmpty(): void
{
$widget = new \WP_Widget();
$this->assertSame('', $this->renderer->processBlockWidgetContent('', [], $widget));
}
public function testProcessBlockWidgetContentMultipleH2(): void
{
$content = '<h2 class="wp-block-heading">First</h2><p>Text</p><h2 class="wp-block-heading">Second</h2>';
$widget = new \WP_Widget();
$result = $this->renderer->processBlockWidgetContent($content, [], $widget);
$this->assertSame(0, substr_count($result, '<h2'));
$this->assertSame(2, substr_count($result, '<h4'));
}
// ── helpers ─────────────────────────────────────────────────
private function makeParams(string $widgetId, string $classes): array
{
return [
[
'widget_id' => $widgetId,
'before_widget' => sprintf('<div id="%s" class="%s">', $widgetId, $classes),
'after_widget' => '</div>',
'before_title' => '<h3 class="sidebar-heading">',
'after_title' => '</h3>',
],
];
}
}

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace WPBootstrap\Tests\Unit\Template;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WPBootstrap\Template\NavWalker;
class NavWalkerTest extends TestCase
{
private NavWalker $walker;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// Default: no page or category is active.
Functions\when('is_page')->justReturn(false);
Functions\when('is_category')->justReturn(false);
$this->walker = new NavWalker();
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
// ── Tree structure ──────────────────────────────────────────
public function testBuildTreeWithEmptyArray(): void
{
$this->assertSame([], $this->walker->buildTree([]));
}
public function testBuildTreeWithSingleItem(): void
{
$items = [$this->makeItem(10, 'Home', '/')];
$tree = $this->walker->buildTree($items);
$this->assertCount(1, $tree);
$this->assertSame('Home', $tree[0]['title']);
$this->assertSame('/', $tree[0]['url']);
}
public function testBuildTreeFlatItemsAllTopLevel(): void
{
$items = [
$this->makeItem(1, 'A', '/a'),
$this->makeItem(2, 'B', '/b'),
$this->makeItem(3, 'C', '/c'),
];
$tree = $this->walker->buildTree($items);
$this->assertCount(3, $tree);
foreach ($tree as $node) {
$this->assertEmpty($node['children']);
}
}
public function testBuildTreeWithChildren(): void
{
$items = [
$this->makeItem(10, 'Home', '/'),
$this->makeItem(20, 'About', '/about'),
$this->makeItem(30, 'Team', '/about/team', parent: 20),
$this->makeItem(40, 'Jobs', '/about/jobs', parent: 20),
];
$tree = $this->walker->buildTree($items);
$this->assertCount(2, $tree);
$this->assertSame('About', $tree[1]['title']);
$this->assertCount(2, $tree[1]['children']);
$this->assertSame('Team', $tree[1]['children'][0]['title']);
$this->assertSame('Jobs', $tree[1]['children'][1]['title']);
}
public function testBuildTreeWithMultipleParents(): void
{
$items = [
$this->makeItem(1, 'A', '/a'),
$this->makeItem(2, 'B', '/b'),
$this->makeItem(3, 'A1', '/a/1', parent: 1),
$this->makeItem(4, 'B1', '/b/1', parent: 2),
];
$tree = $this->walker->buildTree($items);
$this->assertCount(2, $tree);
$this->assertCount(1, $tree[0]['children']);
$this->assertCount(1, $tree[1]['children']);
$this->assertSame('A1', $tree[0]['children'][0]['title']);
$this->assertSame('B1', $tree[1]['children'][0]['title']);
}
public function testBuildTreeOrphansAreDropped(): void
{
$items = [
$this->makeItem(1, 'Root', '/'),
$this->makeItem(2, 'Orphan', '/orphan', parent: 999),
];
$tree = $this->walker->buildTree($items);
$this->assertCount(1, $tree);
$this->assertSame('Root', $tree[0]['title']);
}
public function testBuildTreeNodeStructure(): void
{
$items = [$this->makeItem(42, 'Page', '/page', target: '_blank', classes: ['menu-item', 'custom'])];
$tree = $this->walker->buildTree($items);
$node = $tree[0];
$this->assertSame(42, $node['id']);
$this->assertSame('Page', $node['title']);
$this->assertSame('/page', $node['url']);
$this->assertSame('_blank', $node['target']);
$this->assertSame('menu-item custom', $node['classes']);
$this->assertIsBool($node['active']);
$this->assertIsArray($node['children']);
}
public function testBuildTreeClassesJoined(): void
{
$items = [$this->makeItem(1, 'X', '/x', classes: ['nav-item', '', 'menu-item'])];
$tree = $this->walker->buildTree($items);
// Empty strings are filtered out by array_filter.
$this->assertSame('nav-item menu-item', $tree[0]['classes']);
}
public function testBuildTreeIndexIsReset(): void
{
$items = [
$this->makeItem(50, 'A', '/a'),
$this->makeItem(100, 'B', '/b'),
];
$tree = $this->walker->buildTree($items);
// array_values resets keys to 0-indexed.
$this->assertArrayHasKey(0, $tree);
$this->assertArrayHasKey(1, $tree);
$this->assertArrayNotHasKey(50, $tree);
}
// ── Active detection ────────────────────────────────────────
public function testBuildTreeSetsActiveViaCurrentMenuItem(): void
{
$items = [$this->makeItem(1, 'Active', '/active', classes: ['current-menu-item'])];
$tree = $this->walker->buildTree($items);
$this->assertTrue($tree[0]['active']);
}
public function testBuildTreeSetsActiveViaAncestor(): void
{
$items = [$this->makeItem(1, 'Parent', '/parent', classes: ['current-menu-ancestor'])];
$tree = $this->walker->buildTree($items);
$this->assertTrue($tree[0]['active']);
}
public function testBuildTreeSetsActiveViaIsPage(): void
{
Functions\when('is_page')->alias(fn(int $id): bool => $id === 42);
$items = [$this->makeItem(1, 'Contact', '/contact', object: 'page', objectId: 42)];
$tree = $this->walker->buildTree($items);
$this->assertTrue($tree[0]['active']);
}
public function testBuildTreeSetsActiveViaIsCategory(): void
{
Functions\when('is_category')->alias(fn(int $id): bool => $id === 7);
$items = [$this->makeItem(1, 'News', '/news', object: 'category', objectId: 7)];
$tree = $this->walker->buildTree($items);
$this->assertTrue($tree[0]['active']);
}
public function testBuildTreeNotActive(): void
{
$items = [$this->makeItem(1, 'Inactive', '/inactive')];
$tree = $this->walker->buildTree($items);
$this->assertFalse($tree[0]['active']);
}
// ── Helper ──────────────────────────────────────────────────
private function makeItem(
int $id,
string $title,
string $url,
int $parent = 0,
string $target = '',
array $classes = [],
string $object = 'page',
int $objectId = 0,
): object {
return (object) [
'ID' => $id,
'title' => $title,
'url' => $url,
'target' => $target,
'classes' => $classes,
'menu_item_parent' => $parent,
'object' => $object,
'object_id' => $objectId ?: $id,
];
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace WPBootstrap\Tests\Unit\Template;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
use WPBootstrap\Template\TemplateController;
use ReflectionMethod;
class TemplateControllerTest extends TestCase
{
private TemplateController $controller;
private ReflectionMethod $resolveTemplate;
protected function setUp(): void
{
parent::setUp();
Monkey\setUp();
// TemplateController constructor: new ContextBuilder() + add_action().
// ContextBuilder constructor: new NavWalker() (no WP calls).
Functions\when('is_admin')->justReturn(true);
Functions\when('wp_doing_ajax')->justReturn(false);
Functions\when('add_action')->justReturn(true);
// Default: all WP conditionals return false.
Functions\when('is_404')->justReturn(false);
Functions\when('is_search')->justReturn(false);
Functions\when('is_singular')->justReturn(false);
Functions\when('is_page')->justReturn(false);
Functions\when('is_archive')->justReturn(false);
Functions\when('is_home')->justReturn(false);
Functions\when('is_category')->justReturn(false);
Functions\when('get_page_template_slug')->justReturn('');
$this->controller = new TemplateController();
$this->resolveTemplate = new ReflectionMethod($this->controller, 'resolveTemplate');
$this->resolveTemplate->setAccessible(true);
}
protected function tearDown(): void
{
Monkey\tearDown();
parent::tearDown();
}
private function resolve(): ?string
{
return $this->resolveTemplate->invoke($this->controller);
}
// ── Template resolution ─────────────────────────────────────
public function testResolveTemplate404(): void
{
Functions\when('is_404')->justReturn(true);
$this->assertSame('pages/404.html.twig', $this->resolve());
}
public function testResolveTemplateSearch(): void
{
Functions\when('is_search')->justReturn(true);
$this->assertSame('pages/search.html.twig', $this->resolve());
}
public function testResolveTemplateSinglePostDefault(): void
{
Functions\when('is_singular')->alias(fn($type = '') => $type === 'post');
$this->assertSame('pages/single-sidebar.html.twig', $this->resolve());
}
public function testResolveTemplateSinglePostFullWidth(): void
{
Functions\when('is_singular')->alias(fn($type = '') => $type === 'post');
Functions\when('get_page_template_slug')->justReturn('page-full-width');
$this->assertSame('pages/single.html.twig', $this->resolve());
}
public function testResolveTemplatePageDefault(): void
{
Functions\when('is_page')->justReturn(true);
$this->assertSame('pages/page.html.twig', $this->resolve());
}
public function testResolveTemplatePageLanding(): void
{
Functions\when('is_page')->justReturn(true);
Functions\when('get_page_template_slug')->justReturn('page-landing');
$this->assertSame('pages/landing.html.twig', $this->resolve());
}
public function testResolveTemplatePageFullWidth(): void
{
Functions\when('is_page')->justReturn(true);
Functions\when('get_page_template_slug')->justReturn('page-full-width');
$this->assertSame('pages/full-width.html.twig', $this->resolve());
}
public function testResolveTemplatePageHero(): void
{
Functions\when('is_page')->justReturn(true);
Functions\when('get_page_template_slug')->justReturn('page-hero');
$this->assertSame('pages/hero.html.twig', $this->resolve());
}
public function testResolveTemplatePageSidebar(): void
{
Functions\when('is_page')->justReturn(true);
Functions\when('get_page_template_slug')->justReturn('page-sidebar');
$this->assertSame('pages/page-sidebar.html.twig', $this->resolve());
}
public function testResolveTemplateArchive(): void
{
Functions\when('is_archive')->justReturn(true);
$this->assertSame('pages/archive.html.twig', $this->resolve());
}
public function testResolveTemplateHome(): void
{
Functions\when('is_home')->justReturn(true);
$this->assertSame('pages/index.html.twig', $this->resolve());
}
public function testResolveTemplateFallback(): void
{
// All conditionals already return false in setUp.
$this->assertSame('pages/index.html.twig', $this->resolve());
}
}

15
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,15 @@
<?php
/**
* PHPUnit bootstrap file.
*
* Loads Composer autoloader and WordPress class stubs
* required for unit testing outside of WordPress.
*/
// Composer autoloader (loads WPBootstrap\* classes + Brain\Monkey).
require_once dirname(__DIR__) . '/vendor/autoload.php';
// WordPress class stubs (global namespace).
require_once __DIR__ . '/Stubs/WpHtmlTagProcessor.php';
require_once __DIR__ . '/Stubs/WpBlock.php';
require_once __DIR__ . '/Stubs/WpWidget.php';