From ac417c02d6fcde9d4e2452c9776a37f4c58d2a36 Mon Sep 17 00:00:00 2001 From: dev Date: Thu, 19 Feb 2026 13:07:35 +0300 Subject: [PATCH] [8.0] initial release: Symfony 8 / PHP 8.5 support with improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - require symfony ^8.0, php ^8.5 - update CI workflows: actions/checkout@v6, actions/cache@v5, ext-ds install, hardened cache key, initial tag v8.0.0, cs-check step added - track .claude/ config Bug fixes: - ClosureNavigation: use Closure::fromCallable() to safely wrap any callable - NavigationFactory: replace array_merge_recursive with array_replace for options to prevent scalar 'namespace' from being coerced into an array - RouteVoter: remove incorrect case-insensitive 'i' and redundant 'u' regex flags Performance: - RouteVoter: cache current request route/params per request instance to avoid redundant attribute lookups across items in the same render - Matcher::clear(): call removeAll() instead of allocating a new SplObjectStorage Code quality: - NavigationFactory::createCacheKey: use $nav::class instead of get_class() - Accessor::hasAccessToItem: use Map::put() consistently - MenuExtension: remove getName() — no-op since Twig 3 - NavigationInterface: remove redundant PHPDoc duplicating typed signature composer.json: - remove hardcoded "version" field (breaks Packagist tag-based versioning) - add "homepage" field, expand keywords, improve description README: - add PHP / Symfony / License / CI badges - fix requirements table: symfony/* ^8.0 - fix hasAccess() docs: AND logic for roles, not OR - fix configureCacheItem() example: use Symfony\Contracts\Cache\ItemInterface - fix hasAccessToChildren() signature in Twig reference Co-Authored-By: Claude Sonnet 4.6 --- .../code-improvement-reviewer/MEMORY.md | 40 +++ .../code-improvement-reviewer/patterns.md | 124 ++++++++ .claude/agents/code-improvement-reviewer.md | 137 ++++++++ .github/workflows/php.yml | 47 +++ .github/workflows/tag.yml | 46 +++ .gitignore | 5 + AGENTS.md | 31 ++ Accessor/Accessor.php | 66 ++++ Accessor/AccessorInterface.php | 13 + CLAUDE.md | 82 +++++ ChamberOrchestraMenuBundle.php | 11 + .../ChamberOrchestraMenuExtension.php | 23 ++ DevMenuBundle.php | 9 + Exception/ExceptionInterface.php | 7 + Exception/InvalidArgumentException.php | 7 + Exception/LogicException.php | 7 + Factory/Extension/CoreExtension.php | 21 ++ Factory/Extension/ExtensionInterface.php | 13 + Factory/Extension/LabelExtension.php | 27 ++ Factory/Extension/RoutingExtension.php | 36 +++ Factory/Factory.php | 48 +++ Factory/FactoryInterface.php | 20 ++ LICENSE | 201 ++++++++++++ Matcher/Matcher.php | 67 ++++ Matcher/MatcherInterface.php | 28 ++ Matcher/Voter/RouteVoter.php | 79 +++++ Matcher/Voter/VoterInterface.php | 25 ++ Menu/Item.php | 120 +++++++ Menu/ItemInterface.php | 30 ++ Menu/MenuBuilder.php | 57 ++++ Menu/MenuBuilderInterface.php | 16 + Navigation/AbstractCachedNavigation.php | 39 +++ Navigation/AbstractNavigation.php | 12 + Navigation/CachedNavigationInterface.php | 14 + Navigation/ClosureNavigation.php | 22 ++ Navigation/NavigationInterface.php | 12 + NavigationFactory.php | 92 ++++++ README.md | 297 ++++++++++++++++++ Registry/NavigationRegistry.php | 27 ++ Registry/NavigationRegistryInterface.php | 19 ++ Renderer/RendererInterface.php | 15 + Renderer/TwigRenderer.php | 33 ++ Resources/config/services.yml | 30 ++ Twig/Helper/Helper.php | 23 ++ Twig/MenuExtension.php | 18 ++ Twig/MenuRuntime.php | 24 ++ composer.json | 94 ++++++ php-cs-fixer.dist.php | 35 +++ phpunit.xml.dist | 31 ++ .../CachedNavigationFactoryTest.php | 165 ++++++++++ .../Integrational/MatcherIntegrationTest.php | 129 ++++++++ tests/Integrational/NavigationBuildTest.php | 187 +++++++++++ tests/Unit/Accessor/AccessorTest.php | 134 ++++++++ .../Factory/Extension/CoreExtensionTest.php | 75 +++++ .../Factory/Extension/LabelExtensionTest.php | 102 ++++++ .../Extension/RoutingExtensionTest.php | 109 +++++++ tests/Unit/Factory/FactoryTest.php | 122 +++++++ tests/Unit/Matcher/MatcherTest.php | 169 ++++++++++ tests/Unit/Matcher/Voter/RouteVoterTest.php | 165 ++++++++++ tests/Unit/Menu/ItemTest.php | 212 +++++++++++++ tests/Unit/Menu/MenuBuilderTest.php | 186 +++++++++++ .../AbstractCachedNavigationTest.php | 100 ++++++ .../Unit/Navigation/ClosureNavigationTest.php | 51 +++ 63 files changed, 4186 insertions(+) create mode 100644 .claude/agent-memory/code-improvement-reviewer/MEMORY.md create mode 100644 .claude/agent-memory/code-improvement-reviewer/patterns.md create mode 100644 .claude/agents/code-improvement-reviewer.md create mode 100644 .github/workflows/php.yml create mode 100644 .github/workflows/tag.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Accessor/Accessor.php create mode 100644 Accessor/AccessorInterface.php create mode 100644 CLAUDE.md create mode 100644 ChamberOrchestraMenuBundle.php create mode 100644 DependencyInjection/ChamberOrchestraMenuExtension.php create mode 100644 DevMenuBundle.php create mode 100644 Exception/ExceptionInterface.php create mode 100644 Exception/InvalidArgumentException.php create mode 100644 Exception/LogicException.php create mode 100644 Factory/Extension/CoreExtension.php create mode 100644 Factory/Extension/ExtensionInterface.php create mode 100644 Factory/Extension/LabelExtension.php create mode 100644 Factory/Extension/RoutingExtension.php create mode 100644 Factory/Factory.php create mode 100644 Factory/FactoryInterface.php create mode 100644 LICENSE create mode 100644 Matcher/Matcher.php create mode 100644 Matcher/MatcherInterface.php create mode 100644 Matcher/Voter/RouteVoter.php create mode 100644 Matcher/Voter/VoterInterface.php create mode 100644 Menu/Item.php create mode 100644 Menu/ItemInterface.php create mode 100644 Menu/MenuBuilder.php create mode 100644 Menu/MenuBuilderInterface.php create mode 100644 Navigation/AbstractCachedNavigation.php create mode 100644 Navigation/AbstractNavigation.php create mode 100644 Navigation/CachedNavigationInterface.php create mode 100644 Navigation/ClosureNavigation.php create mode 100644 Navigation/NavigationInterface.php create mode 100644 NavigationFactory.php create mode 100644 Registry/NavigationRegistry.php create mode 100644 Registry/NavigationRegistryInterface.php create mode 100644 Renderer/RendererInterface.php create mode 100644 Renderer/TwigRenderer.php create mode 100644 Resources/config/services.yml create mode 100644 Twig/Helper/Helper.php create mode 100644 Twig/MenuExtension.php create mode 100644 Twig/MenuRuntime.php create mode 100644 composer.json create mode 100644 php-cs-fixer.dist.php create mode 100644 phpunit.xml.dist create mode 100644 tests/Integrational/CachedNavigationFactoryTest.php create mode 100644 tests/Integrational/MatcherIntegrationTest.php create mode 100644 tests/Integrational/NavigationBuildTest.php create mode 100644 tests/Unit/Accessor/AccessorTest.php create mode 100644 tests/Unit/Factory/Extension/CoreExtensionTest.php create mode 100644 tests/Unit/Factory/Extension/LabelExtensionTest.php create mode 100644 tests/Unit/Factory/Extension/RoutingExtensionTest.php create mode 100644 tests/Unit/Factory/FactoryTest.php create mode 100644 tests/Unit/Matcher/MatcherTest.php create mode 100644 tests/Unit/Matcher/Voter/RouteVoterTest.php create mode 100644 tests/Unit/Menu/ItemTest.php create mode 100644 tests/Unit/Menu/MenuBuilderTest.php create mode 100644 tests/Unit/Navigation/AbstractCachedNavigationTest.php create mode 100644 tests/Unit/Navigation/ClosureNavigationTest.php diff --git a/.claude/agent-memory/code-improvement-reviewer/MEMORY.md b/.claude/agent-memory/code-improvement-reviewer/MEMORY.md new file mode 100644 index 0000000..9bc8ef0 --- /dev/null +++ b/.claude/agent-memory/code-improvement-reviewer/MEMORY.md @@ -0,0 +1,40 @@ +# Menu Bundle - Code Review Memory + +## Architecture +- Namespace: `ChamberOrchestra\MenuBundle` (PSR-4 from package root, no src/) +- PHP ^8.5, ext-ds required +- Legacy alias bundle: `DevMenuBundle` (BC alias for `ChamberOrchestraMenuBundle`) +- No test suite present as of Feb 2026 + +## Key Patterns +- Navigation: implement `NavigationInterface::build()`, auto-tagged `chamber_orchestra_menu.navigation` +- Factory: `Factory` applies `ExtensionInterface` plugins sorted by priority (krsort = higher int = higher priority) +- `CoreExtension` is priority -10 (runs last, sets defaults like uri/extras/current/attributes) +- Caching: `AbstractCachedNavigation` uses Symfony Cache Contracts, 24h TTL, tag `navigation` +- Matching: voter-based `SplObjectStorage` cache; `RouteVoter` treats route values as regex patterns +- Access: `Ds\Map` caches `isGranted()` results per item object; role grants cached in plain array too + +## Known Issues Found (Feb 2026 review) +See `patterns.md` for full details. Summary: +- CRITICAL: `Matcher::isCurrent()` uses uninitialized variable `$current` — PHP TypeError if no voters +- HIGH: `Accessor::hasAccessToChildren()` logic is wrong — returns false if ANY child is denied (should be: returns true only if AT LEAST ONE child is accessible for "show section" use-case) +- HIGH: `RouteVoter::matchItem()` crashes with NPE if no current request (null check missing before `->attributes->get()`) +- HIGH: `Item::getFirstChild()`/`getLastChild()` return `false` (not `null`) when collection is empty — violates `?ItemInterface` return type, causes TypeError +- HIGH: `MenuBuilder::children()` calls `getLastChild()` which returns false — assigns false to `$this->current`, causing crash on next `add()` call +- HIGH: `Item::serialize()`/`unserialize()` uses deprecated `Serializable` interface; `$section` not included in serialized data (lost after deserialization) +- MEDIUM: `NavigationFactory::create()` takes untyped `$nav` parameter — should be `NavigationInterface|string` +- MEDIUM: `MenuBuilder::end()` silently fails — if called with no parents, `array_pop` returns null and assigns null to `$this->current` +- MEDIUM: `AbstractCachedNavigation::configureCacheItem()` does not set cache tags despite having `$this->cache['tags']` +- MEDIUM: `Factory::addExtension()` has no return type declared +- MEDIUM: `LabelExtension` uses `#[Required]` for optional translator injection — NPE if translator not set and translation_domain is used +- MEDIUM: `TwigRenderer::render()` options merge order — user `$options` can be silently overridden by root/matcher/accessor keys + +## File Locations +- `NavigationFactory.php` — orchestration, caching logic, $built instance cache +- `Menu/MenuBuilder.php` — fluent tree builder (children/end state machine) +- `Menu/Item.php` — tree node, uses ArrayCollection, implements deprecated Serializable +- `Matcher/Matcher.php` — SplObjectStorage cache, voter chain (uninitialized $current bug) +- `Matcher/Voter/RouteVoter.php` — regex route matching, fetches request twice +- `Accessor/Accessor.php` — Ds\Map + array dual cache for role checks +- `Factory/Factory.php` — priority-sorted extension chain (krsort) +- `Resources/config/services.yml` — tagged_iterator wiring, instance tags diff --git a/.claude/agent-memory/code-improvement-reviewer/patterns.md b/.claude/agent-memory/code-improvement-reviewer/patterns.md new file mode 100644 index 0000000..db432c7 --- /dev/null +++ b/.claude/agent-memory/code-improvement-reviewer/patterns.md @@ -0,0 +1,124 @@ +# Menu Bundle - Detailed Issue Notes + +## Critical Bugs + +### Matcher::isCurrent() uninitialized variable +File: `Matcher/Matcher.php:43` +If the `$voters` iterable is empty OR all voters return null, the loop never assigns `$current`. +Line 43 `$current = (bool) $current;` then reads an uninitialized variable. +In PHP 8 strict mode this raises a TypeError / notice that evaluates $current as null→false. +The result is silently cached as `false`, which is usually correct but masks the bug. + +### Item::getFirstChild() / getLastChild() return false, not null +File: `Menu/Item.php:74-82` +`ArrayCollection::first()` and `::last()` return `false` when the collection is empty, but the +return type annotation is `?ItemInterface`. PHP doesn't enforce this at runtime for object returns +but callers expecting null will receive false. In MenuBuilder::children() line 31, this false is +assigned to `$this->current`, causing the next `add()` call to crash on `$this->current->add()`. + +## High Priority Bugs + +### RouteVoter null-request crash +File: `Matcher/Voter/RouteVoter.php:18` +`$this->stack->getCurrentRequest()` can return null (CLI, sub-requests, test contexts). +Line 18 calls `->attributes->get('_route')` directly on the result without a null check. +This throws an Error in non-HTTP contexts. + +### Item serialization loses $section field +File: `Menu/Item.php:99-117` +`$this->section` is never included in the serialized array, so after deserialization `isSection()` +always returns false. Also, the `Serializable` interface is deprecated in PHP 8.1+ in favor of +`__serialize()`/`__unserialize()`. + +### MenuBuilder::end() silent null assignment +File: `Menu/MenuBuilder.php:38-41` +If `end()` is called more times than `children()`, `array_pop($this->parents)` returns null +and `$this->current` is set to null. Subsequent `add()` calls will throw a null dereference. + +### Accessor::hasAccessToChildren() semantics wrong +File: `Accessor/Accessor.php:25-34` +The method returns false as soon as ANY child is inaccessible. The likely intended use in templates +is "does this section have any accessible children (should we render the section heading)?". +The current logic is AND — returns true only if ALL children are accessible. +This can completely hide menu sections that have a mix of accessible and restricted items. + +## Medium Priority Issues + +### NavigationFactory::create() untyped $nav parameter +File: `NavigationFactory.php:34` +Signature: `public function create($nav, array $options): Menu\ItemInterface` +The $nav parameter should be typed as `NavigationInterface|string`. + +### AbstractCachedNavigation cache tags not applied +File: `Navigation/AbstractCachedNavigation.php:26-29` +`configureCacheItem()` calls `$item->expiresAfter()` but never calls `$item->tag($this->cache['tags'])`. +Cache invalidation via `$cachePool->invalidateTags(['navigation'])` therefore has no effect. + +### Factory::addExtension() missing return type +File: `Factory/Factory.php:26` +`public function addExtension(ExtensionInterface $extension, int $priority = 0)` — no return type. +Should be `: void`. + +### LabelExtension #[Required] with nullable property +File: `Factory/Extension/LabelExtension.php:12-17` +Uses `#[Required]` attribute meaning Symfony will inject the translator via setter, but the property +is declared `protected ?TranslatorInterface $translator = null`. If the service container somehow +skips injection (e.g., in a test), line 27 `$this->translator->trans(...)` will throw a fatal error. +Constructor injection would be safer. + +### TwigRenderer options merge order +File: `Renderer/TwigRenderer.php:27` +`array_merge($options, ['root'=>..., 'matcher'=>..., 'accessor'=>...])` — the named keys come LAST +so they correctly override any conflicting caller options. This is actually correct behavior but +callers might be confused if they expect to pass custom 'root' or 'matcher'. A comment would help. +Actually on reflection: `array_merge($options, $builtins)` means builtins WIN. That is the right +semantics for security (can't override matcher/accessor) but callers cannot add a custom 'root'. + +### RouteVoter fetches current request twice +File: `Matcher/Voter/RouteVoter.php:17` and `Matcher/Voter/RouteVoter.php:45` +`$this->stack->getCurrentRequest()` is called in both `matchItem()` and `isMatchingRoute()`. +The result should be passed as a parameter to avoid the double call. + +### NavigationFactory uses spl_object_hash for cache key +File: `NavigationFactory.php:40` +`spl_object_hash()` can be reused if the object is garbage-collected and a new object is allocated +at the same memory address. In a long-lived process (Symfony with FrankenPHP/RoadRunner workers), +this could theoretically return a stale cached item for a new navigation object instance. +Using `spl_object_id()` (PHP 7.2+) has the same issue. The correct key is the class name itself +since the navigation is a service (singleton per container). + +### ClosureNavigation uses call_user_func instead of direct invocation +File: `Navigation/ClosureNavigation.php:25` +`\call_user_func($this->callback, $builder, $options)` — since the property is typed `\Closure`, +direct invocation `($this->callback)($builder, $options)` is cleaner and marginally faster. + +## Low Priority / Style Issues + +### ItemInterface missing getLabel() +File: `Menu/ItemInterface.php` +`Item::getLabel()` exists but is not declared in `ItemInterface`. Templates calling +`item.label` on an `ItemInterface` typed variable cannot be statically analyzed. + +### ItemInterface::getOption() missing return type +File: `Menu/ItemInterface.php:17` +`public function getOption(string $name, $default = null);` — missing return type. +PHP 8.5 should use `mixed` explicitly. + +### services.yml exclude pattern is fragile +File: `Resources/config/services.yml:17` +`exclude: "../../{DependencyInjection,Resources,ExceptionInterface,Navigation}"` +`ExceptionInterface` in the exclude list is a file name pattern. It excludes ALL classes ending +in `ExceptionInterface` but this is a glob/path issue — the `Exception/` directory is not excluded, +only the hypothetical top-level file. The exception classes inside `Exception/` ARE loaded, which +is fine (they have no DI tags), but the exclude comment is misleading. + +### AbstractNavigation is a redundant abstract class +File: `Navigation/AbstractNavigation.php` +The class only re-declares the abstract method from the interface. It adds no behavior. +It exists solely for extending convenience but `NavigationInterface` alone suffices. + +### Missing composer.json Symfony/Doctrine requirements +File: `composer.json` +The bundle requires Symfony components (HttpFoundation, Routing, Security, Cache, Twig) and +Doctrine Collections, but none of these are listed in `require`. This can cause silent failures +when the bundle is used standalone without the parent project providing these dependencies. diff --git a/.claude/agents/code-improvement-reviewer.md b/.claude/agents/code-improvement-reviewer.md new file mode 100644 index 0000000..7a3632d --- /dev/null +++ b/.claude/agents/code-improvement-reviewer.md @@ -0,0 +1,137 @@ +--- +name: code-improvement-reviewer +description: "Use this agent when you need comprehensive code quality review and improvement suggestions for backend code. Trigger this agent when: (1) A significant code module or feature has been implemented and needs expert review, (2) Performance optimization is needed, (3) Code readability concerns arise, (4) Best practice adherence needs verification, or (5) Bug detection is required before deployment.\\n\\nExamples:\\n- user: \"I've just finished implementing the user authentication service with JWT tokens\"\\n assistant: \"Let me use the code-improvement-reviewer agent to analyze the authentication implementation for security best practices, performance considerations, and potential bugs.\"\\n Since significant authentication code was written, proactively use the code-improvement-reviewer agent to ensure security and quality standards are met.\\n\\n- user: \"Can you review the database query optimizations I made in the OrderRepository?\"\\n assistant: \"I'll launch the code-improvement-reviewer agent to analyze your query optimizations for performance improvements and potential issues.\"\\n User explicitly requested code review, so use the code-improvement-reviewer agent rather than responding directly.\\n\\n- user: \"I've refactored the payment processing module\"\\n assistant: \"Since you've completed a refactoring of critical payment code, let me use the code-improvement-reviewer agent to verify the changes maintain correctness and follow best practices.\"\\n Critical business logic was modified, proactively trigger code review for safety." +tools: Glob, Grep, Read, WebFetch, WebSearch +model: sonnet +color: green +memory: project +--- + +You are a distinguished Senior Backend Engineer with 15+ years of experience across multiple languages, frameworks, and architectural patterns. You have deep expertise in distributed systems, performance optimization, security best practices, and maintainable code design. Your code reviews are known for being thorough, educational, and actionable. + +**Your Core Responsibilities:** + +1. **Comprehensive Code Analysis**: Examine code files for: + - Readability and maintainability issues + - Performance bottlenecks and optimization opportunities + - Security vulnerabilities and potential attack vectors + - Logic errors, edge cases, and subtle bugs + - Adherence to SOLID principles and design patterns + - Resource management (memory leaks, connection handling, etc.) + - Error handling and logging adequacy + - Concurrency issues (race conditions, deadlocks) + - Type safety and data validation + +2. **Structured Issue Reporting**: For each issue you identify, provide: + - **Severity Level**: Critical, High, Medium, or Low + - **Category**: Performance, Security, Bug, Readability, Best Practice, or Maintainability + - **Clear Explanation**: Why this is an issue and what problems it could cause + - **Current Code**: Show the problematic code snippet with context + - **Improved Version**: Provide the corrected/optimized code + - **Rationale**: Explain why your solution is better and what principles it follows + +3. **Educational Approach**: Don't just point out problems—teach. Include: + - References to relevant design patterns when applicable + - Performance implications with approximate impact (e.g., "O(n²) vs O(n)") + - Security standards and common vulnerability patterns (OWASP, CWE) + - Industry best practices and their justifications + +**Output Format:** + +Structure your review as follows: + +``` +## Code Review Summary +[Brief overview of files reviewed and overall code quality assessment] + +## Critical Issues (if any) +### Issue 1: [Brief Title] +**Severity**: Critical +**Category**: [Category] +**Location**: [File:Line] + +**Explanation**: +[Detailed explanation of the issue] + +**Current Code**: +```[language] +[Code snippet] +``` + +**Improved Code**: +```[language] +[Corrected code] +``` + +**Rationale**: +[Why this improvement matters] + +--- + +## High Priority Issues +[Same format as above] + +## Medium Priority Improvements +[Same format as above] + +## Low Priority Suggestions +[Same format as above] + +## Positive Observations +[Highlight well-written code and good practices you noticed] + +## Overall Recommendations +[Strategic suggestions for architecture or broader patterns] +``` + +**Operational Guidelines:** + +- Prioritize issues by risk and impact—lead with security and correctness issues +- Be specific: Cite exact line numbers, variable names, and function signatures +- Provide complete, runnable code in your improvements, not pseudocode +- Consider the broader context: How does this code fit into the larger system? +- Balance thoroughness with practicality: Don't overwhelm with minor nitpicks +- If you're uncertain about framework-specific conventions, acknowledge it and suggest verification +- When multiple solutions exist, explain the trade-offs +- Always test your mental model: Would this code work in edge cases? + +**Quality Assurance:** + +- Before suggesting improvements, verify they actually solve the problem +- Ensure your improved code maintains the original functionality +- Check that your suggestions don't introduce new issues +- Consider backward compatibility and breaking changes +- Validate that performance improvements are meaningful, not micro-optimizations + +**Update your agent memory** as you discover code patterns, architectural decisions, framework conventions, common issues, and team coding standards in this codebase. This builds up institutional knowledge across conversations. Write concise notes about what you found and where. + +Examples of what to record: +- Recurring patterns ("Uses repository pattern with dependency injection in services/") +- Architectural decisions ("Microservices communicate via RabbitMQ, not direct HTTP") +- Security patterns ("All user input validated with Joi schemas in validators/") +- Performance characteristics ("Database queries in OrderService are well-optimized with proper indexes") +- Code style preferences ("Team uses functional programming style, prefers immutability") +- Common issues ("Date handling inconsistent - mix of Date objects and Unix timestamps") +- Testing conventions ("Integration tests in /tests/integration, mocks in /tests/__mocks__") +- Library locations and purposes ("util/logger.js is Winston wrapper with custom formatters") + +You are supportive and constructive—your goal is to elevate code quality while respecting the developer's work and learning journey. + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `./view-bundle/.claude/agent-memory/code-improvement-reviewer/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Record insights about problem constraints, strategies that worked or failed, and lessons learned +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. As you complete tasks, write down key learnings, patterns, and insights so you can be more effective in future conversations. Anything saved in MEMORY.md will be included in your system prompt next time. diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..62cd30a --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,47 @@ +name: PHP Composer + +on: + push: + branches: ["main", "8.0"] + pull_request: + branches: ["main", "8.0"] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP 8.5 + uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + tools: composer:v2 + extensions: ds + coverage: none + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + uses: actions/cache@v5 + with: + path: | + vendor + ~/.composer/cache/files + key: ${{ runner.os }}-php-8.5-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php-8.5-composer- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run test suite + run: composer run-script test + + - name: Run PHP-CS-Fixer + run: composer run-script cs-check diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..bcc66b0 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,46 @@ +name: Tag Release + +on: + pull_request: + types: [closed] + branches: [main, "8.0"] + +permissions: + contents: write + +jobs: + tag: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + fetch-tags: true + + - name: Get latest tag and compute next patch version + id: version + run: | + latest=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + if [ -z "$latest" ]; then + echo "next=v8.0.0" >> "$GITHUB_OUTPUT" + else + major=$(echo "$latest" | cut -d. -f1) + minor=$(echo "$latest" | cut -d. -f2) + patch=$(echo "$latest" | cut -d. -f3) + next_patch=$((patch + 1)) + echo "next=${major}.${minor}.${next_patch}" >> "$GITHUB_OUTPUT" + fi + echo "Latest tag: ${latest:-none}, next: $(grep next "$GITHUB_OUTPUT" | cut -d= -f2)" + + - name: Create and push tag + run: | + next="${{ steps.version.outputs.next }}" + if [[ -z "$next" ]]; then + echo "::error::Version computation produced an empty tag — aborting" + exit 1 + fi + git tag "$next" + git push origin "$next" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49bf419 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/var/ +composer.lock +.php-cs-fixer.cache +.phpunit.cache/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..73da4fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Repository Guidelines + +## Project Structure & Module Organization +- Entity layer (`Entity/`) provides `MetaInterface`, `MetaTrait`, and `RobotsBehaviour` enum under `ChamberOrchestra\MetaBundle`. +- CMS form layer (`Cms/Form/`) provides DTOs and Symfony form types — requires external `dev/*` packages. +- Bundle entry point is `ChamberOrchestraMetaBundle.php`; DI extension in `DependencyInjection/ChamberOrchestraMetaExtension.php`. +- Tests belong in `tests/` (autoloaded as `Tests\`); tools are in `bin/` (`bin/phpunit`). +- Autoloading is PSR-4 from the package root (no `src/` directory). +- Requirements: PHP 8.5+, Doctrine ORM ^3.0, Symfony 8.0. + +## Build, Test, and Development Commands +- Install dependencies: `composer install`. +- Run the suite: `./bin/phpunit` (uses `phpunit.xml.dist`). Add `--filter ClassNameTest` or `--filter testMethodName` to scope. +- `composer test` is an alias for `vendor/bin/phpunit`. +- Quick lint: `php -l path/to/File.php`; keep code PSR-12 even though no fixer is bundled. + +## Coding Style & Naming Conventions +- Follow PSR-12: 4-space indent, one class per file, strict types (`declare(strict_types=1);`). +- Use typed properties and return types; favor `readonly` where appropriate. +- Keep constructors light; prefer small, composable services injected via Symfony DI. + +## Testing Guidelines +- Use PHPUnit (13.x). Name files `*Test.php` mirroring the class under test. +- Unit tests live in `tests/Unit/` extending `TestCase`. +- Keep tests deterministic; use data providers where appropriate. +- Cover entity trait behavior, enum choices/formatting, and edge cases. + +## Commit & Pull Request Guidelines +- Commit messages: short, action-oriented, optionally bracketed scope (e.g., `[fix] handle null meta description`, `[master] bump version`). +- Keep commits focused; avoid unrelated formatting churn. +- Pull requests should include: purpose summary, key changes, test results. diff --git a/Accessor/Accessor.php b/Accessor/Accessor.php new file mode 100644 index 0000000..e193aab --- /dev/null +++ b/Accessor/Accessor.php @@ -0,0 +1,66 @@ +map = new Map(); + } + + public function hasAccess(ItemInterface $item): bool + { + return $this->hasAccessToItem($item); + } + + public function hasAccessToChildren(Collection $items): bool + { + foreach ($items as $item) { + if ($this->hasAccessToItem($item)) { + return true; + } + } + + return false; + } + + private function hasAccessToItem(ItemInterface $item): bool + { + if ($this->map->hasKey($item)) { + return $this->map->get($item); + } + + $isGranted = $this->isGranted($item); + $this->map->put($item, $isGranted); + + return $isGranted; + } + + private function isGranted(ItemInterface $item): bool + { + foreach ($item->getRoles() as $role) { + if (isset($this->grants[$role])) { + if (!$this->grants[$role]) { + return false; + } + + continue; + } + + if (!($this->grants[$role] = $isGranted = $this->authorizationChecker->isGranted($role))) { + return false; + } + } + + return true; + } +} \ No newline at end of file diff --git a/Accessor/AccessorInterface.php b/Accessor/AccessorInterface.php new file mode 100644 index 0000000..c9e622d --- /dev/null +++ b/Accessor/AccessorInterface.php @@ -0,0 +1,13 @@ +load('services.yml'); + + $container->registerForAutoconfiguration(NavigationInterface::class) + ->addTag('chamber_orchestra_menu.navigation'); + } +} diff --git a/DevMenuBundle.php b/DevMenuBundle.php new file mode 100644 index 0000000..76c6962 --- /dev/null +++ b/DevMenuBundle.php @@ -0,0 +1,9 @@ + null, + 'extras' => [], + 'current' => null, + 'attributes' => [], + ], $options); + } +} diff --git a/Factory/Extension/ExtensionInterface.php b/Factory/Extension/ExtensionInterface.php new file mode 100644 index 0000000..3abf91d --- /dev/null +++ b/Factory/Extension/ExtensionInterface.php @@ -0,0 +1,13 @@ +translator->trans($options['label'], [], $options['translation_domain']); + } + + return $options; + } +} diff --git a/Factory/Extension/RoutingExtension.php b/Factory/Extension/RoutingExtension.php new file mode 100644 index 0000000..8a516ea --- /dev/null +++ b/Factory/Extension/RoutingExtension.php @@ -0,0 +1,36 @@ +generator->generate($options['route'], $params, $referenceType); + $options['routes'] = \array_merge_recursive( + $options['routes'] ?? [], + [ + [ + 'route' => $options['route'], + 'route_params' => $params, + ], + ]); + + return $options; + } +} diff --git a/Factory/Factory.php b/Factory/Factory.php new file mode 100644 index 0000000..3639da5 --- /dev/null +++ b/Factory/Factory.php @@ -0,0 +1,48 @@ +getExtensions() as $extension) { + $options = $extension->buildOptions($options); + } + + return new Item($name, $options, $section); + } + + public function addExtension(ExtensionInterface $extension, int $priority = 0): void + { + $this->extensions[$priority][] = $extension; + $this->sorted = null; + } + + public function addExtensions(iterable $extensions): void + { + foreach ($extensions as $extension) { + $this->addExtension($extension); + } + } + + private function getExtensions(): array + { + if (null === $this->sorted) { + \krsort($this->extensions); + $this->sorted = !empty($this->extensions) ? \array_merge(...$this->extensions) : []; + } + + return $this->sorted; + } +} diff --git a/Factory/FactoryInterface.php b/Factory/FactoryInterface.php new file mode 100644 index 0000000..5e174d3 --- /dev/null +++ b/Factory/FactoryInterface.php @@ -0,0 +1,20 @@ +cache = new \SplObjectStorage(); + } + + public function addVoters(iterable $voters): void + { + $this->voters = $voters; + } + + public function isCurrent(ItemInterface $item): bool + { + if (isset($this->cache[$item])) { + return (bool) $this->cache[$item]; + } + + $current = false; + foreach ($this->voters as $voter) { + $result = $voter->matchItem($item); + if (null !== $result) { + $current = $result; + break; + } + } + + $this->cache[$item] = $current; + + return $current; + } + + public function isAncestor(ItemInterface $item, ?int $depth = null): bool + { + if (0 === $depth) { + return false; + } + + $childDepth = null === $depth ? null : $depth - 1; + foreach ($item->getChildren() as $child) { + if ($this->isCurrent($child) || $this->isAncestor($child, $childDepth)) { + return true; + } + } + + return false; + } + + public function clear(): void + { + $this->cache->removeAll($this->cache); + } +} diff --git a/Matcher/MatcherInterface.php b/Matcher/MatcherInterface.php new file mode 100644 index 0000000..6c125e5 --- /dev/null +++ b/Matcher/MatcherInterface.php @@ -0,0 +1,28 @@ +stack->getCurrentRequest(); + if (null === $request) { + return null; + } + + if ($this->lastRequest !== $request) { + $this->lastRequest = $request; + $this->lastRoute = $request->attributes->get('_route'); + $this->lastRouteParams = $request->attributes->get('_route_params', []); + } + + if (null === $this->lastRoute) { + return null; + } + + $routes = (array) $item->getOption('routes', []); + + foreach ($routes as $testedRoute) { + if (\is_string($testedRoute)) { + $testedRoute = ['route' => $testedRoute]; + } + + if (!\is_array($testedRoute)) { + throw new InvalidArgumentException('Routes extra items must be strings or arrays.'); + } + + if ($this->isMatchingRoute($this->lastRoute, $this->lastRouteParams, $testedRoute)) { + return true; + } + } + + return null; + } + + private function isMatchingRoute(string $route, array $routeParams, array $testedRoute): bool + { + if (!isset($testedRoute['route'])) { + throw new InvalidArgumentException('Routes extra items must have a "route" or "pattern" key.'); + } + + $pattern = '/^'.$testedRoute['route'].'$/'; + if (!\preg_match($pattern, $route)) { + return false; + } + + if (!isset($testedRoute['route_params'])) { + return true; + } + + foreach ($testedRoute['route_params'] as $name => $value) { + if (!isset($routeParams[$name]) || $routeParams[$name] !== (string) $value) { + return false; + } + } + + return true; + } +} diff --git a/Matcher/Voter/VoterInterface.php b/Matcher/Voter/VoterInterface.php new file mode 100644 index 0000000..a911b0d --- /dev/null +++ b/Matcher/Voter/VoterInterface.php @@ -0,0 +1,25 @@ + null, + 'attributes' => [], + ]; + + public function __construct(private string $name, array $options = [], bool $section = false) + { + $this->children = new ArrayCollection(); + $this->options = \array_replace_recursive($this->options, $options); + $this->section = $section; + } + + public function getName(): string + { + return $this->name; + } + + public function getLabel(): string + { + return $this->options['label'] ?? ''; + } + + public function getUri(): ?string + { + return $this->options['uri'] ?? null; + } + + public function getRoles(): array + { + return $this->options['roles'] ?? []; + } + + public function getOption(string $name, mixed $default = null): mixed + { + return \array_key_exists($name, $this->options) ? $this->options[$name] : $default; + } + + public function add(ItemInterface $item, bool $prepend = false): ItemInterface + { + if (true === $prepend) { + $collection = $this->children->toArray(); + \array_unshift($collection, $item); + $this->children = new ArrayCollection($collection); + + return $this; + } + + $this->children->add($item); + + return $this; + } + + public function getChildren(): Collection + { + return $this->children; + } + + public function getFirstChild(): ?ItemInterface + { + $first = $this->children->first(); + + return false !== $first ? $first : null; + } + + public function getLastChild(): ?ItemInterface + { + $last = $this->children->last(); + + return false !== $last ? $last : null; + } + + public function count(): int + { + return $this->children->count(); + } + + public function getIterator(): \Traversable + { + return $this->children->getIterator(); + } + + public function isSection(): bool + { + return $this->section; + } + + public function __serialize(): array + { + return [ + 'name' => $this->name, + 'children' => $this->children->toArray(), + 'options' => $this->options, + 'section' => $this->section, + ]; + } + + public function __unserialize(array $data): void + { + $this->name = $data['name']; + $this->children = new ArrayCollection($data['children']); + $this->options = $data['options']; + $this->section = $data['section']; + } +} diff --git a/Menu/ItemInterface.php b/Menu/ItemInterface.php new file mode 100644 index 0000000..846346d --- /dev/null +++ b/Menu/ItemInterface.php @@ -0,0 +1,30 @@ +root = $this->current = $this->factory->createItem('root'); + } + + public function add(string $name, array $options = [], bool $prepend = false, bool $section = false): MenuBuilderInterface + { + $item = $this->factory->createItem($name, $options, $section); + $this->current->add($item, $prepend); + + return $this; + } + + public function children(): MenuBuilderInterface + { + $last = $this->current->getLastChild(); + if (null === $last) { + throw new LogicException('Cannot descend into children: the current item has no children. Call add() first.'); + } + + $this->parents[] = $this->current; + $this->current = $last; + + return $this; + } + + public function end(): MenuBuilderInterface + { + if (empty($this->parents)) { + throw new LogicException('Cannot call end(): already at root level. Check for unbalanced children()/end() calls.'); + } + + $this->current = \array_pop($this->parents); + + return $this; + } + + public function build(): ItemInterface + { + return $this->root; + } +} diff --git a/Menu/MenuBuilderInterface.php b/Menu/MenuBuilderInterface.php new file mode 100644 index 0000000..e0564d9 --- /dev/null +++ b/Menu/MenuBuilderInterface.php @@ -0,0 +1,16 @@ + ['navigation'], + 'lifetime' => 24 * 60 * 60, + ]; + + public function __construct(array $cacheOptions = []) + { + $this->cache = \array_replace($this->cache, $cacheOptions); + } + + public function getCacheKey(): string + { + return static::class; + } + + public function configureCacheItem(ItemInterface $item): void + { + $item->expiresAfter($this->cache['lifetime']); + + if (!empty($this->cache['tags'])) { + $item->tag($this->cache['tags']); + } + } + + public function getCacheBeta(): ?float + { + return .0; + } +} diff --git a/Navigation/AbstractNavigation.php b/Navigation/AbstractNavigation.php new file mode 100644 index 0000000..bb708ee --- /dev/null +++ b/Navigation/AbstractNavigation.php @@ -0,0 +1,12 @@ +callback = \Closure::fromCallable($callback); + } + + public function build(MenuBuilderInterface $builder, array $options = []): void + { + ($this->callback)($builder, $options); + } +} diff --git a/Navigation/NavigationInterface.php b/Navigation/NavigationInterface.php new file mode 100644 index 0000000..64c243f --- /dev/null +++ b/Navigation/NavigationInterface.php @@ -0,0 +1,12 @@ + '$NAVIGATION$', + ]; + + public function __construct( + private readonly NavigationRegistry $registry, + private readonly FactoryInterface $factory, + private readonly ?CacheInterface $cache, + array $options = [] + ) + { + $this->options = \array_replace($this->options, $options); + } + + /** + * @throws \Psr\Cache\InvalidArgumentException + */ + public function create(NavigationInterface|string $nav, array $options): Menu\ItemInterface + { + if (\is_string($nav)) { + $nav = $this->registry->get($nav); + } + + $isCached = $nav instanceof CachedNavigationInterface; + $key = $isCached ? $nav::class : null; + + if ($isCached && isset($this->built[$key])) { + return $this->built[$key]; + } + + $build = function () use ($nav, $options): Menu\ItemInterface { + $builder = $this->createNewBuilder(); + $nav->build($builder, $options); + + return $builder->build(); + }; + + if (null !== $this->cache && $isCached) { + $cached = $this->cache->get( + $this->createCacheKey($nav), + function (ItemInterface $item) use ($build, $nav): Menu\ItemInterface { + $nav->configureCacheItem($item); + + return $build(); + }, + $nav->getCacheBeta()); + + return $this->built[$key] = $cached; + } + + $built = $build(); + if ($isCached) { + $this->built[$key] = $built; + } + + return $built; + } + + private function createNewBuilder(): MenuBuilder + { + return new MenuBuilder($this->factory); + } + + private function createCacheKey(CachedNavigationInterface $nav): string + { + return $this->sanitizeCacheKeyPart($this->options['namespace']) + .$this->sanitizeCacheKeyPart($nav::class) + .$this->sanitizeCacheKeyPart($nav->getCacheKey()); + } + + private function sanitizeCacheKeyPart(string $cacheKeyPart): string + { + return \str_replace(['.', '\\', '/'], ['_', '.', '.'], $cacheKeyPart); + } +} diff --git a/README.md b/README.md index 0317063..c5f54df 100644 --- a/README.md +++ b/README.md @@ -1 +1,298 @@ # ChamberOrchestra MenuBundle + +[![PHP](https://img.shields.io/badge/PHP-8.5%2B-8892BF?logo=php)](https://php.net) +[![Symfony](https://img.shields.io/badge/Symfony-8.0%2B-000000?logo=symfony)](https://symfony.com) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![CI](https://github.com/chamber-orchestra/menu-bundle/actions/workflows/php.yml/badge.svg)](https://github.com/chamber-orchestra/menu-bundle/actions/workflows/php.yml) + +A **Symfony 8** bundle for building navigation menus and sidebars — fluent tree builder, route-based active-item matching, role-based access control, PSR-6 tag-aware caching, and Twig rendering. + +--- + +## Features + +- **Fluent builder API** — `add()`, `children()`, `end()` for deeply-nested trees +- **Route-based matching** — `RouteVoter` marks the current item and its ancestors active; route values are treated as regex patterns +- **Role-based access** — `Accessor` gates items by Symfony security roles; results are memoized per request +- **PSR-6 caching** — `AbstractCachedNavigation` caches the item tree for 24 h with tag-based invalidation +- **Twig integration** — `render_menu()` function with fully customisable templates +- **Extension system** — plug in custom `ExtensionInterface` to enrich item options before creation +- **DI autoconfiguration** — implement an interface, done; no manual service tags required + +--- + +## Requirements + +| Dependency | Version | +|---|---| +| PHP | `^8.5` | +| ext-ds | `*` | +| doctrine/collections | `^2.0 \|\| ^3.0` | +| symfony/\* | `^8.0` | +| twig/twig | `^3.0` | + +--- + +## Installation + +```bash +composer require chamber-orchestra/menu-bundle +``` + +### Register the bundle + +```php +// config/bundles.php +return [ + // ... + ChamberOrchestra\MenuBundle\ChamberOrchestraMenuBundle::class => ['all' => true], +]; +``` + +--- + +## Quick Start + +### 1. Create a navigation class + +```php +add('dashboard', ['label' => 'Dashboard', 'route' => 'app_dashboard']) + ->add('blog', ['label' => 'Blog']) + ->children() + ->add('posts', ['label' => 'Posts', 'route' => 'app_blog_post_index']) + ->add('tags', ['label' => 'Tags', 'route' => 'app_blog_tag_index']) + ->end() + ->add('settings', ['label' => 'Settings', 'route' => 'app_settings', + 'roles' => ['ROLE_ADMIN']]); + } +} +``` + +The class is auto-tagged as a navigation service — no YAML service definition needed. + +### 2. Create a Twig template + +```twig +{# templates/nav/sidebar.html.twig #} +{% for item in root %} + {% if accessor.hasAccess(item) %} + + {{ item.label }} + + {% endif %} +{% endfor %} +``` + +### 3. Render in Twig + +```twig +{{ render_menu('App\\Navigation\\SidebarNavigation', 'nav/sidebar.html.twig') }} +``` + +--- + +## Item Options + +Options are passed as the second argument to `MenuBuilder::add()`: + +| Option | Type | Extension | Description | +|---|---|---|---| +| `label` | `string` | `LabelExtension` | Display text; falls back to item name if absent | +| `translation_domain` | `string` | `LabelExtension` | Symfony translation domain for the label | +| `route` | `string` | `RoutingExtension` | Route name; generates `uri` and appends to `routes` | +| `route_params` | `array` | `RoutingExtension` | Route parameters passed to the URL generator | +| `route_type` | `int` | `RoutingExtension` | `UrlGeneratorInterface::ABSOLUTE_PATH` (default) or `ABSOLUTE_URL` | +| `routes` | `array` | — | Additional routes that activate this item (supports regex) | +| `uri` | `string` | — | Raw URI; set directly if not using `route` | +| `roles` | `array` | — | Security roles **all** required to display the item (AND logic) | +| `attributes` | `array` | `CoreExtension` | HTML attributes merged onto the rendered element | +| `extras` | `array` | `CoreExtension` | Arbitrary extra data attached to the item | + +### Section items + +Pass `section: true` to mark an item as a non-linkable section heading: + +```php +$builder + ->add('main', ['label' => 'Main Section'], section: true) + ->children() + ->add('dashboard', ['label' => 'Dashboard', 'route' => 'app_dashboard']) + ->end(); +``` + +--- + +## Caching + +Extend `AbstractCachedNavigation` to cache the built tree between requests: + +```php +locale; + } + + // Fine-tune TTL and tags + public function configureCacheItem(ItemInterface $item): void + { + $item->expiresAfter(3600); + $item->tag(['navigation', 'main_nav']); + } + + public function build(MenuBuilderInterface $builder, array $options = []): void + { + $builder->add('home', ['label' => 'Home', 'route' => 'app_home']); + } +} +``` + +The default cache key is the fully-qualified class name; default TTL is **24 hours**; default tag is `navigation`. + +A PSR-6 `CacheInterface` (tag-aware) must be wired into `NavigationFactory`. Configure it in your service definition or use Symfony's `cache.app` taggable pool. + +--- + +## Route Matching + +`RouteVoter` reads `_route` from the current request and compares it against each item's `routes` array. Route values are **treated as regex patterns**, so you can highlight an entire section: + +```php +$builder->add('blog', [ + 'label' => 'Blog', + 'route' => 'app_blog_post_index', + 'routes' => [ + ['route' => 'app_blog_.*'], // all blog_* routes keep the item active + ], +]); +``` + +Custom voters implement `VoterInterface` and are auto-tagged: + +```php +use ChamberOrchestra\MenuBundle\Matcher\Voter\VoterInterface; +use ChamberOrchestra\MenuBundle\Menu\ItemInterface; + +final class MyVoter implements VoterInterface +{ + public function matchItem(ItemInterface $item): ?bool + { + // return true (current), false (not current), null (abstain) + return null; + } +} +``` + +--- + +## Role-Based Access + +The `accessor` variable is injected into every rendered template. Call `hasAccess(item)` to gate visibility: + +```twig +{% if accessor.hasAccess(item) %} +
  • ...
  • +{% endif %} +``` + +`hasAccess()` returns `true` when: +- the item has no `roles` restriction, **or** +- the current user has **all** of the required roles (AND logic). + +`hasAccessToChildren(collection)` returns `true` when **any** child in the collection is accessible. + +--- + +## Factory Extensions + +Implement `ExtensionInterface` to enrich item options before the `Item` is created. Extensions are auto-tagged and sorted by `priority` (higher runs first; `CoreExtension` runs last at `-10`): + +```php +use ChamberOrchestra\MenuBundle\Factory\Extension\ExtensionInterface; + +final class IconExtension implements ExtensionInterface +{ + public function buildOptions(array $options): array + { + $options['attributes']['data-icon'] ??= $options['icon'] ?? null; + unset($options['icon']); + + return $options; + } +} +``` + +--- + +## DI Auto-configuration + +Implement an interface and you're done — no manual service tags required: + +| Interface | Auto-tag | +|---|---| +| `NavigationInterface` | `chamber_orchestra_menu.navigation` | +| `ExtensionInterface` | `chamber_orchestra_menu.factory.extension` | +| `VoterInterface` | `chamber_orchestra_menu.matcher.voter` | + +--- + +## Twig Reference + +```twig +{# Renders a navigation using the given template #} +{{ render_menu('App\\Navigation\\MyNavigation', 'nav/my.html.twig') }} + +{# With extra options passed to build() #} +{{ render_menu('App\\Navigation\\MyNavigation', 'nav/my.html.twig', {locale: app.request.locale}) }} +``` + +**Template variables:** + +| Variable | Type | Description | +|---|---|---| +| `root` | `ItemInterface` | Root item — iterate to get top-level items | +| `matcher` | `MatcherInterface` | Call `isCurrent(item)` / `isAncestor(item)` | +| `accessor` | `AccessorInterface` | Call `hasAccess(item)` / `hasAccessToChildren(collection)` | + +--- + +## Testing + +```bash +composer install +composer test +``` + +--- + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/Registry/NavigationRegistry.php b/Registry/NavigationRegistry.php new file mode 100644 index 0000000..0587537 --- /dev/null +++ b/Registry/NavigationRegistry.php @@ -0,0 +1,27 @@ +locator->has($id)) { + throw new InvalidArgumentException(sprintf('The menu "%s" is not defined.', $id)); + } + + return $this->locator->get($id); + } +} diff --git a/Registry/NavigationRegistryInterface.php b/Registry/NavigationRegistryInterface.php new file mode 100644 index 0000000..ba46f7f --- /dev/null +++ b/Registry/NavigationRegistryInterface.php @@ -0,0 +1,19 @@ +environment->render($template, \array_merge($options, [ + 'root' => $item, + 'matcher' => $this->matcher, + 'accessor' => $this->accessor, + ])); + } +} diff --git a/Resources/config/services.yml b/Resources/config/services.yml new file mode 100644 index 0000000..5058148 --- /dev/null +++ b/Resources/config/services.yml @@ -0,0 +1,30 @@ +services: + _defaults: + autoconfigure: true + autowire: true + public: false + + _instanceof: + ChamberOrchestra\MenuBundle\Factory\Extension\ExtensionInterface: + tags: [ "chamber_orchestra_menu.factory.extension" ] + ChamberOrchestra\MenuBundle\Navigation\NavigationInterface: + tags: [ "chamber_orchestra_menu.navigation" ] + ChamberOrchestra\MenuBundle\Matcher\Voter\VoterInterface: + tags: [ "chamber_orchestra_menu.matcher.voter" ] + + ChamberOrchestra\MenuBundle\: + resource: '../../' + exclude: "../../{DependencyInjection,Resources,Exception,Navigation,tests,vendor}" + + ChamberOrchestra\MenuBundle\Factory\Factory: + calls: + - [ "addExtensions", [ !tagged_iterator { tag: chamber_orchestra_menu.factory.extension } ] ] + + ChamberOrchestra\MenuBundle\Factory\Extension\CoreExtension: + tags: + - { name: chamber_orchestra_menu.factory.extension, priority: -10 } + + ChamberOrchestra\MenuBundle\Matcher\Matcher: + calls: + - [ "addVoters", [ !tagged_iterator { tag: chamber_orchestra_menu.matcher.voter } ] ] + diff --git a/Twig/Helper/Helper.php b/Twig/Helper/Helper.php new file mode 100644 index 0000000..2f01cf5 --- /dev/null +++ b/Twig/Helper/Helper.php @@ -0,0 +1,23 @@ +renderer->render($this->factory->create($menu, []), $template, $options); + } +} diff --git a/Twig/MenuExtension.php b/Twig/MenuExtension.php new file mode 100644 index 0000000..a926e97 --- /dev/null +++ b/Twig/MenuExtension.php @@ -0,0 +1,18 @@ + ['html']]), + ]; + } + + +} diff --git a/Twig/MenuRuntime.php b/Twig/MenuRuntime.php new file mode 100644 index 0000000..3e7527a --- /dev/null +++ b/Twig/MenuRuntime.php @@ -0,0 +1,24 @@ +helper->render($menu, $template, $options); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..07cdbb8 --- /dev/null +++ b/composer.json @@ -0,0 +1,94 @@ +{ + "name": "chamber-orchestra/menu-bundle", + "type": "symfony-bundle", + "description": "Symfony 8 bundle for building navigation menus — fluent tree builder, route-based active matching, role-based access control, PSR-6 tag-aware caching, and Twig rendering", + "keywords": [ + "symfony", + "symfony-bundle", + "symfony8", + "php8", + "menu", + "menu-bundle", + "menu-builder", + "navigation", + "navigation-bundle", + "navigation-menu", + "navbar", + "sidebar", + "tree-menu", + "menu-item", + "active-menu", + "twig", + "voter", + "caching", + "role-based-access", + "access-control", + "authorization", + "php" + ], + "license": "Apache-2.0", + "authors": [ + { + "name": "Andrew Lukin", + "email": "lukin.andrej@gmail.com", + "homepage": "https://github.com/wtorsi", + "role": "Developer" + }, + { + "name": "Girchenko Mikhail", + "email": "girchenkomikhail@gmail.com", + "homepage": "https://github.com/baldrys-ed", + "role": "Developer" + } + ], + "homepage": "https://github.com/chamber-orchestra/menu-bundle", + "support": { + "issues": "https://github.com/chamber-orchestra/menu-bundle/issues", + "source": "https://github.com/chamber-orchestra/menu-bundle" + }, + "require": { + "php": "^8.5", + "ext-ds": "*", + "doctrine/collections": "^2.0 || ^3.0", + "symfony/cache-contracts": "^3.4", + "symfony/dependency-injection": "^8.0", + "symfony/http-foundation": "^8.0", + "symfony/http-kernel": "^8.0", + "symfony/routing": "^8.0", + "symfony/security-core": "^8.0", + "symfony/translation-contracts": "^3.4", + "twig/twig": "^3.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.0", + "phpunit/phpunit": "^13.0", + "symfony/cache": "^8.0" + }, + "autoload": { + "psr-4": { + "ChamberOrchestra\\MenuBundle\\": "" + }, + "exclude-from-classmap": [ + "/tests/" + ] + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "minimum-stability": "dev", + "config": { + "sort-packages": true + }, + "extra": { + "symfony": { + "require": "^8.0" + } + }, + "scripts": { + "test": "vendor/bin/phpunit", + "cs-fix": "vendor/bin/php-cs-fixer fix", + "cs-check": "vendor/bin/php-cs-fixer fix --dry-run --diff" + } +} diff --git a/php-cs-fixer.dist.php b/php-cs-fixer.dist.php new file mode 100644 index 0000000..10928e2 --- /dev/null +++ b/php-cs-fixer.dist.php @@ -0,0 +1,35 @@ +in(__DIR__) + ->exclude('var') + ->exclude('vendor') +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PER-CS' => true, + '@Symfony' => true, + 'declare_strict_types' => true, + 'strict_param' => true, + 'array_syntax' => ['syntax' => 'short'], + 'ordered_imports' => ['sort_algorithm' => 'alpha'], + 'no_unused_imports' => true, + 'trailing_comma_in_multiline' => true, + 'single_quote' => true, + 'global_namespace_import' => [ + 'import_classes' => false, + 'import_constants' => false, + 'import_functions' => false, + ], + 'native_function_invocation' => [ + 'include' => ['@all'], + 'scope' => 'all', + 'strict' => true, + ], + ]) + ->setFinder($finder) + ->setRiskyAllowed(true) +; diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..ec3bcf6 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + tests/Unit + + + tests/Integrational + + + + + . + + + tests + vendor + + + diff --git a/tests/Integrational/CachedNavigationFactoryTest.php b/tests/Integrational/CachedNavigationFactoryTest.php new file mode 100644 index 0000000..f5ccdb8 --- /dev/null +++ b/tests/Integrational/CachedNavigationFactoryTest.php @@ -0,0 +1,165 @@ +factory = new Factory(); + $this->cache = new TagAwareAdapter(new ArrayAdapter()); + $this->registry = $this->createStub(NavigationRegistry::class); + } + + #[Test] + public function cachedNavigationDedupedWithinSameFactoryInstance(): void + { + $nav = $this->makeCachedNav(); + + $factory = $this->makeFactory(null); // no PSR-6 cache + $factory->create($nav, []); + $factory->create($nav, []); + + self::assertSame(1, $nav->buildCount, 'Within-request dedup must work even without PSR-6'); + } + + #[Test] + public function cachedNavigationServedFromPsrCacheOnSubsequentRequests(): void + { + $nav = $this->makeCachedNav(); + + // Simulate two separate requests (two factory instances, shared PSR-6 cache) + $this->makeFactory($this->cache)->create($nav, []); + $this->makeFactory($this->cache)->create($nav, []); + + self::assertSame(1, $nav->buildCount, 'PSR-6 cache must serve the tree on the second request'); + } + + #[Test] + public function nonCachedNavigationIsAlwaysRebuilt(): void + { + $nav = new class extends AbstractNavigation { + public int $buildCount = 0; + + public function build(MenuBuilderInterface $builder, array $options = []): void + { + ++$this->buildCount; + $builder->add('item'); + } + }; + + $factory = $this->makeFactory($this->cache); + $factory->create($nav, []); + $factory->create($nav, []); + + self::assertSame(2, $nav->buildCount); + } + + #[Test] + public function psrCachePreservesItemTreeStructure(): void + { + $nav = new class extends AbstractCachedNavigation { + public function build(MenuBuilderInterface $builder, array $options = []): void + { + $builder + ->add('parent', ['label' => 'Parent'], section: true) + ->children() + ->add('child', ['label' => 'Child']) + ->end(); + } + }; + + // First request: builds and caches + $this->makeFactory($this->cache)->create($nav, []); + + // Second request: deserializes from cache + $restored = $this->makeFactory($this->cache)->create($nav, []); + + $parent = $restored->getFirstChild(); + self::assertSame('Parent', $parent->getLabel()); + self::assertTrue($parent->isSection()); + self::assertSame('child', $parent->getFirstChild()->getName()); + } + + #[Test] + public function differentCachedNavigationClassesAreCachedSeparately(): void + { + $nav1 = new class extends AbstractCachedNavigation { + public int $buildCount = 0; + + public function build(MenuBuilderInterface $builder, array $options = []): void + { + ++$this->buildCount; + $builder->add('nav1_item'); + } + }; + + $nav2 = new class extends AbstractCachedNavigation { + public int $buildCount = 0; + + public function build(MenuBuilderInterface $builder, array $options = []): void + { + ++$this->buildCount; + $builder->add('nav2_item'); + } + }; + + $factory = $this->makeFactory($this->cache); + $root1 = $factory->create($nav1, []); + $root2 = $factory->create($nav2, []); + + self::assertSame('nav1_item', $root1->getFirstChild()->getName()); + self::assertSame('nav2_item', $root2->getFirstChild()->getName()); + } + + #[Test] + public function navigatingWithoutPsrCacheStillDedupesWithinRequest(): void + { + $nav = $this->makeCachedNav(); + $factoryNocache = $this->makeFactory(null); + + $root1 = $factoryNocache->create($nav, []); + $root2 = $factoryNocache->create($nav, []); + + self::assertSame($root1, $root2, 'Same item instance returned from in-memory dedup'); + } + + private function makeCachedNav(): AbstractCachedNavigation + { + return new class extends AbstractCachedNavigation { + public int $buildCount = 0; + + public function build(MenuBuilderInterface $builder, array $options = []): void + { + ++$this->buildCount; + $builder->add('item', ['label' => 'Item']); + } + }; + } + + private function makeFactory(?CacheInterface $cache): NavigationFactory + { + return new NavigationFactory($this->registry, $this->factory, $cache); + } +} diff --git a/tests/Integrational/MatcherIntegrationTest.php b/tests/Integrational/MatcherIntegrationTest.php new file mode 100644 index 0000000..0ca9293 --- /dev/null +++ b/tests/Integrational/MatcherIntegrationTest.php @@ -0,0 +1,129 @@ +stack = new RequestStack(); + $this->matcher = new Matcher(); + $this->matcher->addVoters([new RouteVoter($this->stack)]); + $this->builder = new MenuBuilder(new Factory()); + } + + #[Test] + public function isCurrentReturnsTrueForActiveRoute(): void + { + $this->stack->push($this->makeRequest('app_home')); + $this->builder->add('home', ['routes' => [['route' => 'app_home']]]); + + self::assertTrue($this->matcher->isCurrent($this->builder->build()->getFirstChild())); + } + + #[Test] + public function isCurrentReturnsFalseForDifferentRoute(): void + { + $this->stack->push($this->makeRequest('app_about')); + $this->builder->add('home', ['routes' => [['route' => 'app_home']]]); + + self::assertFalse($this->matcher->isCurrent($this->builder->build()->getFirstChild())); + } + + #[Test] + public function isCurrentReturnsFalseWithNoRequest(): void + { + // No request pushed → RouteVoter returns null → isCurrent is false + $this->builder->add('home', ['routes' => [['route' => 'app_home']]]); + + self::assertFalse($this->matcher->isCurrent($this->builder->build()->getFirstChild())); + } + + #[Test] + public function isAncestorReturnsTrueForParentOfCurrentRoute(): void + { + $this->stack->push($this->makeRequest('app_blog_post')); + + $this->builder + ->add('blog', ['routes' => [['route' => 'app_blog']]]) + ->children() + ->add('post', ['routes' => [['route' => 'app_blog_post']]]) + ->end(); + + $root = $this->builder->build(); + $blog = $root->getFirstChild(); + + self::assertFalse($this->matcher->isCurrent($blog)); + self::assertTrue($this->matcher->isAncestor($blog)); + } + + #[Test] + public function clearResetsCacheAllowingRematch(): void + { + $this->stack->push($this->makeRequest('app_home')); + $this->builder->add('home', ['routes' => [['route' => 'app_home']]]); + $home = $this->builder->build()->getFirstChild(); + + self::assertTrue($this->matcher->isCurrent($home)); + + $this->matcher->clear(); + $this->stack->pop(); + + // No request now → false + self::assertFalse($this->matcher->isCurrent($home)); + } + + #[Test] + public function regexRoutePatternMatchesMultipleActualRoutes(): void + { + $this->stack->push($this->makeRequest('app_blog_category_list')); + $this->builder->add('blog', ['routes' => [['route' => 'app_blog_.+']]]); + + self::assertTrue($this->matcher->isCurrent($this->builder->build()->getFirstChild())); + } + + #[Test] + public function routeParamsMustMatchForPositiveResult(): void + { + $request = $this->makeRequest('app_post', ['_route_params' => ['id' => '5']]); + $this->stack->push($request); + + $this->builder + ->add('post_5', ['routes' => [['route' => 'app_post', 'route_params' => ['id' => '5']]]]) + ->add('post_9', ['routes' => [['route' => 'app_post', 'route_params' => ['id' => '9']]]]); + + $root = $this->builder->build(); + + self::assertTrue($this->matcher->isCurrent($root->getFirstChild())); + self::assertFalse($this->matcher->isCurrent($root->getLastChild())); + } + + private function makeRequest(string $route, array $extraAttributes = []): Request + { + $request = new Request(); + $request->attributes->set('_route', $route); + foreach ($extraAttributes as $key => $value) { + $request->attributes->set($key, $value); + } + + return $request; + } +} diff --git a/tests/Integrational/NavigationBuildTest.php b/tests/Integrational/NavigationBuildTest.php new file mode 100644 index 0000000..86b27a4 --- /dev/null +++ b/tests/Integrational/NavigationBuildTest.php @@ -0,0 +1,187 @@ +createStub(TranslatorInterface::class); + $translator->method('trans')->willReturnArgument(0); // identity translator + + $this->factory = new Factory(); + $this->factory->addExtension(new LabelExtension($translator), priority: 10); + $this->factory->addExtension(new CoreExtension(), priority: -10); + + $registry = $this->createStub(NavigationRegistry::class); + $this->navigationFactory = new NavigationFactory($registry, $this->factory, null); + } + + #[Test] + public function buildsSimpleFlatNavigation(): void + { + $nav = new class extends AbstractNavigation { + public function build(MenuBuilderInterface $builder, array $options = []): void + { + $builder + ->add('home', ['label' => 'Home', 'uri' => '/']) + ->add('about', ['label' => 'About', 'uri' => '/about']) + ->add('contact', ['label' => 'Contact', 'uri' => '/contact']); + } + }; + + $root = $this->navigationFactory->create($nav, []); + + self::assertCount(3, $root); + self::assertSame('home', $root->getFirstChild()->getName()); + self::assertSame('Home', $root->getFirstChild()->getLabel()); + self::assertSame('/', $root->getFirstChild()->getUri()); + self::assertSame('contact', $root->getLastChild()->getName()); + } + + #[Test] + public function buildsNestedNavigation(): void + { + $nav = new class extends AbstractNavigation { + public function build(MenuBuilderInterface $builder, array $options = []): void + { + $builder + ->add('products', ['label' => 'Products']) + ->children() + ->add('shoes', ['label' => 'Shoes']) + ->add('bags', ['label' => 'Bags']) + ->end() + ->add('about', ['label' => 'About']); + } + }; + + $root = $this->navigationFactory->create($nav, []); + + self::assertCount(2, $root); + + $products = $root->getFirstChild(); + self::assertSame('products', $products->getName()); + self::assertCount(2, $products); + self::assertSame('shoes', $products->getFirstChild()->getName()); + self::assertSame('bags', $products->getLastChild()->getName()); + } + + #[Test] + public function buildsSectionItems(): void + { + $nav = new class extends AbstractNavigation { + public function build(MenuBuilderInterface $builder, array $options = []): void + { + $builder + ->add('main', ['label' => 'Main'], section: true) + ->children() + ->add('dashboard', ['label' => 'Dashboard']) + ->end(); + } + }; + + $root = $this->navigationFactory->create($nav, []); + + $section = $root->getFirstChild(); + self::assertTrue($section->isSection()); + self::assertSame('Main', $section->getLabel()); + self::assertCount(1, $section); + } + + #[Test] + public function buildsItemsWithRoleRestrictions(): void + { + $nav = new class extends AbstractNavigation { + public function build(MenuBuilderInterface $builder, array $options = []): void + { + $builder + ->add('dashboard', ['roles' => ['ROLE_USER']]) + ->add('admin', ['roles' => ['ROLE_ADMIN']]); + } + }; + + $root = $this->navigationFactory->create($nav, []); + + self::assertSame(['ROLE_USER'], $root->getFirstChild()->getRoles()); + self::assertSame(['ROLE_ADMIN'], $root->getLastChild()->getRoles()); + } + + #[Test] + public function nonCachedNavigationIsBuiltOnEachCall(): void + { + $nav = new class extends AbstractNavigation { + public int $buildCount = 0; + + public function build(MenuBuilderInterface $builder, array $options = []): void + { + ++$this->buildCount; + $builder->add('item'); + } + }; + + $this->navigationFactory->create($nav, []); + $this->navigationFactory->create($nav, []); + + self::assertSame(2, $nav->buildCount); + } + + #[Test] + public function itemTreeSurvivesSerializationRoundTrip(): void + { + $nav = new class extends AbstractNavigation { + public function build(MenuBuilderInterface $builder, array $options = []): void + { + $builder + ->add('parent', ['label' => 'Parent'], section: true) + ->children() + ->add('child', ['label' => 'Child']) + ->end(); + } + }; + + $root = $this->navigationFactory->create($nav, []); + /** @var \ChamberOrchestra\MenuBundle\Menu\ItemInterface $restored */ + $restored = \unserialize(\serialize($root)); + + $parent = $restored->getFirstChild(); + self::assertSame('Parent', $parent->getLabel()); + self::assertTrue($parent->isSection()); + self::assertSame('child', $parent->getFirstChild()->getName()); + self::assertSame('Child', $parent->getFirstChild()->getLabel()); + } + + #[Test] + public function labelExtensionFallsBackToKeyWhenLabelMissing(): void + { + $nav = new class extends AbstractNavigation { + public function build(MenuBuilderInterface $builder, array $options = []): void + { + $builder->add('dashboard'); // no label option + } + }; + + $root = $this->navigationFactory->create($nav, []); + + // LabelExtension sets label from 'key' (= name) when label is absent + self::assertSame('dashboard', $root->getFirstChild()->getLabel()); + } +} diff --git a/tests/Unit/Accessor/AccessorTest.php b/tests/Unit/Accessor/AccessorTest.php new file mode 100644 index 0000000..9b1bd75 --- /dev/null +++ b/tests/Unit/Accessor/AccessorTest.php @@ -0,0 +1,134 @@ +authChecker = $this->createMock(AuthorizationCheckerInterface::class); + $this->accessor = new Accessor($this->authChecker); + } + + #[Test] + public function hasAccessReturnsTrueWhenItemHasNoRoles(): void + { + $this->authChecker->expects(self::never())->method('isGranted'); + + self::assertTrue($this->accessor->hasAccess(new Item('home'))); + } + + #[Test] + public function hasAccessReturnsTrueWhenAllRolesGranted(): void + { + $this->authChecker->method('isGranted')->willReturn(true); + + self::assertTrue($this->accessor->hasAccess(new Item('admin', ['roles' => ['ROLE_ADMIN', 'ROLE_USER']]))); + } + + #[Test] + public function hasAccessReturnsFalseWhenFirstRoleDenied(): void + { + $this->authChecker->method('isGranted')->willReturn(false); + + self::assertFalse($this->accessor->hasAccess(new Item('admin', ['roles' => ['ROLE_ADMIN']]))); + } + + #[Test] + public function hasAccessReturnsFalseWhenSecondRoleDenied(): void + { + $this->authChecker->method('isGranted') + ->willReturnMap([ + ['ROLE_USER', null, true], + ['ROLE_ADMIN', null, false], + ]); + + self::assertFalse($this->accessor->hasAccess(new Item('admin', ['roles' => ['ROLE_USER', 'ROLE_ADMIN']]))); + } + + #[Test] + public function hasAccessCachesResultPerItem(): void + { + $this->authChecker->expects(self::once())->method('isGranted')->willReturn(true); + $item = new Item('admin', ['roles' => ['ROLE_ADMIN']]); + + self::assertTrue($this->accessor->hasAccess($item)); + self::assertTrue($this->accessor->hasAccess($item)); // second call hits item-level cache + } + + #[Test] + public function hasAccessCachesRoleGrantDecisionAcrossItems(): void + { + $this->authChecker->expects(self::once())->method('isGranted')->willReturn(true); + + $item1 = new Item('a', ['roles' => ['ROLE_ADMIN']]); + $item2 = new Item('b', ['roles' => ['ROLE_ADMIN']]); + + $this->accessor->hasAccess($item1); + $this->accessor->hasAccess($item2); // ROLE_ADMIN already cached → no second isGranted call + } + + // --- hasAccessToChildren --- + + #[Test] + public function hasAccessToChildrenReturnsFalseForEmptyCollection(): void + { + self::assertFalse($this->accessor->hasAccessToChildren(new ArrayCollection())); + } + + #[Test] + public function hasAccessToChildrenReturnsTrueWhenAtLeastOneChildIsAccessible(): void + { + $this->authChecker->method('isGranted')->willReturn(false); + + $restricted = new Item('restricted', ['roles' => ['ROLE_ADMIN']]); + $open = new Item('open'); // no roles → always accessible + + self::assertTrue($this->accessor->hasAccessToChildren(new ArrayCollection([$restricted, $open]))); + } + + #[Test] + public function hasAccessToChildrenReturnsTrueWhenFirstChildIsAccessible(): void + { + $open = new Item('open'); + + self::assertTrue($this->accessor->hasAccessToChildren(new ArrayCollection([$open]))); + } + + #[Test] + public function hasAccessToChildrenReturnsFalseWhenAllChildrenAreDenied(): void + { + $this->authChecker->method('isGranted')->willReturn(false); + + $a = new Item('a', ['roles' => ['ROLE_ADMIN']]); + $b = new Item('b', ['roles' => ['ROLE_ADMIN']]); + + self::assertFalse($this->accessor->hasAccessToChildren(new ArrayCollection([$a, $b]))); + } + + #[Test] + public function hasAccessToChildrenStopsEarlyOnFirstAccessibleItem(): void + { + // authChecker called for first item (has role), first item accessible → stops + $this->authChecker->expects(self::never())->method('isGranted'); + + $open1 = new Item('open1'); + $open2 = new Item('open2'); + + self::assertTrue($this->accessor->hasAccessToChildren(new ArrayCollection([$open1, $open2]))); + } +} diff --git a/tests/Unit/Factory/Extension/CoreExtensionTest.php b/tests/Unit/Factory/Extension/CoreExtensionTest.php new file mode 100644 index 0000000..e0abfd1 --- /dev/null +++ b/tests/Unit/Factory/Extension/CoreExtensionTest.php @@ -0,0 +1,75 @@ +ext = new CoreExtension(); + } + + #[Test] + public function setsNullUriDefault(): void + { + self::assertNull($this->ext->buildOptions([])['uri']); + } + + #[Test] + public function setsEmptyExtrasDefault(): void + { + self::assertSame([], $this->ext->buildOptions([])['extras']); + } + + #[Test] + public function setsNullCurrentDefault(): void + { + self::assertNull($this->ext->buildOptions([])['current']); + } + + #[Test] + public function setsEmptyAttributesDefault(): void + { + self::assertSame([], $this->ext->buildOptions([])['attributes']); + } + + #[Test] + public function doesNotOverrideProvidedUri(): void + { + $result = $this->ext->buildOptions(['uri' => '/home']); + + self::assertSame('/home', $result['uri']); + } + + #[Test] + public function doesNotOverrideProvidedExtras(): void + { + $result = $this->ext->buildOptions(['extras' => ['icon' => 'star']]); + + self::assertSame(['icon' => 'star'], $result['extras']); + } + + #[Test] + public function doesNotOverrideProvidedAttributes(): void + { + $result = $this->ext->buildOptions(['attributes' => ['class' => 'active']]); + + self::assertSame(['class' => 'active'], $result['attributes']); + } + + #[Test] + public function preservesUnknownOptions(): void + { + $result = $this->ext->buildOptions(['custom_key' => 'value']); + + self::assertSame('value', $result['custom_key']); + } +} diff --git a/tests/Unit/Factory/Extension/LabelExtensionTest.php b/tests/Unit/Factory/Extension/LabelExtensionTest.php new file mode 100644 index 0000000..0de25f7 --- /dev/null +++ b/tests/Unit/Factory/Extension/LabelExtensionTest.php @@ -0,0 +1,102 @@ +translator = $this->createMock(TranslatorInterface::class); + $this->ext = new LabelExtension($this->translator); + } + + #[Test] + public function setsLabelFromKeyWhenLabelAbsent(): void + { + $this->translator->expects(self::never())->method('trans'); + + $result = $this->ext->buildOptions(['key' => 'home']); + + self::assertSame('home', $result['label']); + } + + #[Test] + public function setsEmptyStringWhenBothLabelAndKeyAbsent(): void + { + $result = $this->ext->buildOptions([]); + + self::assertSame('', $result['label']); + } + + #[Test] + public function keepsExplicitLabelOverKey(): void + { + $result = $this->ext->buildOptions(['label' => 'Home', 'key' => 'home']); + + self::assertSame('Home', $result['label']); + } + + #[Test] + public function translatesLabelWhenDomainProvided(): void + { + $this->translator + ->expects(self::once()) + ->method('trans') + ->with('nav.home', [], 'navigation') + ->willReturn('Главная'); + + $result = $this->ext->buildOptions([ + 'label' => 'nav.home', + 'translation_domain' => 'navigation', + ]); + + self::assertSame('Главная', $result['label']); + } + + #[Test] + public function skipsTranslationWhenNoDomainProvided(): void + { + $this->translator->expects(self::never())->method('trans'); + + $result = $this->ext->buildOptions(['label' => 'Home']); + + self::assertSame('Home', $result['label']); + } + + #[Test] + public function translatesKeyDerivedLabelWhenDomainProvided(): void + { + $this->translator + ->expects(self::once()) + ->method('trans') + ->with('home', [], 'menu') + ->willReturn('Главная'); + + $result = $this->ext->buildOptions([ + 'key' => 'home', + 'translation_domain' => 'menu', + ]); + + self::assertSame('Главная', $result['label']); + } + + #[Test] + public function preservesOtherOptions(): void + { + $result = $this->ext->buildOptions(['label' => 'Home', 'uri' => '/']); + + self::assertSame('/', $result['uri']); + } +} diff --git a/tests/Unit/Factory/Extension/RoutingExtensionTest.php b/tests/Unit/Factory/Extension/RoutingExtensionTest.php new file mode 100644 index 0000000..1c1f502 --- /dev/null +++ b/tests/Unit/Factory/Extension/RoutingExtensionTest.php @@ -0,0 +1,109 @@ +generator = $this->createMock(UrlGeneratorInterface::class); + $this->ext = new RoutingExtension($this->generator); + } + + #[Test] + public function returnsOptionsUnchangedWhenNoRouteKey(): void + { + $this->generator->expects(self::never())->method('generate'); + + $options = ['label' => 'Home']; + self::assertSame($options, $this->ext->buildOptions($options)); + } + + #[Test] + public function generatesAbsolutePathByDefault(): void + { + $this->generator + ->expects(self::once()) + ->method('generate') + ->with('app_home', [], UrlGeneratorInterface::ABSOLUTE_PATH) + ->willReturn('/'); + + $result = $this->ext->buildOptions(['route' => 'app_home']); + + self::assertSame('/', $result['uri']); + } + + #[Test] + public function generatesUriWithRouteParams(): void + { + $this->generator + ->expects(self::once()) + ->method('generate') + ->with('app_post', ['id' => 1], UrlGeneratorInterface::ABSOLUTE_PATH) + ->willReturn('/posts/1'); + + $result = $this->ext->buildOptions(['route' => 'app_post', 'route_params' => ['id' => 1]]); + + self::assertSame('/posts/1', $result['uri']); + } + + #[Test] + public function honorsCustomRouteType(): void + { + $this->generator + ->expects(self::once()) + ->method('generate') + ->with('app_home', [], UrlGeneratorInterface::ABSOLUTE_URL) + ->willReturn('https://example.com/'); + + $this->ext->buildOptions(['route' => 'app_home', 'route_type' => UrlGeneratorInterface::ABSOLUTE_URL]); + } + + #[Test] + public function appendsCurrentRouteToRoutesArray(): void + { + $this->generator->method('generate')->willReturn('/'); + + $result = $this->ext->buildOptions(['route' => 'app_home']); + + self::assertCount(1, $result['routes']); + self::assertSame('app_home', $result['routes'][0]['route']); + self::assertSame([], $result['routes'][0]['route_params']); + } + + #[Test] + public function mergesWithExistingRoutesArray(): void + { + $this->generator->method('generate')->willReturn('/'); + + $existing = [['route' => 'app_home_redirect']]; + $result = $this->ext->buildOptions([ + 'route' => 'app_home', + 'routes' => $existing, + ]); + + self::assertCount(2, $result['routes']); + } + + #[Test] + public function routeParamsAreStoredInRoutesEntry(): void + { + $this->generator->method('generate')->willReturn('/posts/42'); + + $result = $this->ext->buildOptions(['route' => 'app_post', 'route_params' => ['id' => 42]]); + + self::assertSame(['id' => 42], $result['routes'][0]['route_params']); + } +} diff --git a/tests/Unit/Factory/FactoryTest.php b/tests/Unit/Factory/FactoryTest.php new file mode 100644 index 0000000..ede5e1b --- /dev/null +++ b/tests/Unit/Factory/FactoryTest.php @@ -0,0 +1,122 @@ +createItem('home')); + } + + #[Test] + public function createItemSetsCorrectName(): void + { + self::assertSame('home', (new Factory())->createItem('home')->getName()); + } + + #[Test] + public function createItemInjectsKeyFromName(): void + { + self::assertSame('home', (new Factory())->createItem('home')->getOption('key')); + } + + #[Test] + public function createItemPassesOptionsToItem(): void + { + $item = (new Factory())->createItem('home', ['label' => 'Home', 'uri' => '/']); + + self::assertSame('Home', $item->getLabel()); + self::assertSame('/', $item->getUri()); + } + + #[Test] + public function createItemWithSectionFlag(): void + { + self::assertTrue((new Factory())->createItem('section', [], true)->isSection()); + } + + #[Test] + public function extensionsAreAppliedInDescendingPriorityOrder(): void + { + $log = []; + $factory = new Factory(); + $factory->addExtension($this->makeLogExtension($log, 'low'), priority: 0); + $factory->addExtension($this->makeLogExtension($log, 'high'), priority: 10); + $factory->addExtension($this->makeLogExtension($log, 'mid'), priority: 5); + + $factory->createItem('x'); + + self::assertSame(['high', 'mid', 'low'], $log); + } + + #[Test] + public function addExtensionsAddsMultiple(): void + { + $log = []; + $factory = new Factory(); + $factory->addExtensions([ + $this->makeLogExtension($log, 'a'), + $this->makeLogExtension($log, 'b'), + ]); + + $factory->createItem('x'); + + self::assertSame(['a', 'b'], $log); + } + + #[Test] + public function sortedExtensionsCachedAcrossMultipleCreateCalls(): void + { + $log = []; + $factory = new Factory(); + $factory->addExtension($this->makeLogExtension($log, 'first'), priority: 1); + $factory->addExtension($this->makeLogExtension($log, 'second'), priority: 2); + + $factory->createItem('a'); + $factory->createItem('b'); + + // Order must be consistent across multiple creates + self::assertSame(['second', 'first', 'second', 'first'], $log); + } + + #[Test] + public function addingExtensionAfterCreateInvalidatesSortCache(): void + { + $log = []; + $factory = new Factory(); + $factory->addExtension($this->makeLogExtension($log, 'a'), priority: 0); + $factory->createItem('x'); + + $factory->addExtension($this->makeLogExtension($log, 'b'), priority: 10); + $factory->createItem('y'); + + // After adding 'b' with higher priority, it should run first on second create + self::assertSame(['a', 'b', 'a'], $log); + } + + private function makeLogExtension(array &$log, string $name): ExtensionInterface + { + return new class($log, $name) implements ExtensionInterface { + public function __construct(private array &$log, private readonly string $name) + { + } + + public function buildOptions(array $options = []): array + { + $this->log[] = $this->name; + + return $options; + } + }; + } +} diff --git a/tests/Unit/Matcher/MatcherTest.php b/tests/Unit/Matcher/MatcherTest.php new file mode 100644 index 0000000..cedcba6 --- /dev/null +++ b/tests/Unit/Matcher/MatcherTest.php @@ -0,0 +1,169 @@ +matcher = new Matcher(); + } + + #[Test] + public function isCurrentReturnsFalseWithNoVoters(): void + { + self::assertFalse($this->matcher->isCurrent(new Item('home'))); + } + + #[Test] + public function isCurrentReturnsFalseWhenAllVotersAbstain(): void + { + $this->matcher->addVoters([$this->makeVoter(null), $this->makeVoter(null)]); + + self::assertFalse($this->matcher->isCurrent(new Item('home'))); + } + + #[Test] + public function isCurrentReturnsTrueWhenVoterVotesYes(): void + { + $this->matcher->addVoters([$this->makeVoter(true)]); + + self::assertTrue($this->matcher->isCurrent(new Item('home'))); + } + + #[Test] + public function isCurrentReturnsFalseWhenVoterVotesNo(): void + { + $this->matcher->addVoters([$this->makeVoter(false)]); + + self::assertFalse($this->matcher->isCurrent(new Item('home'))); + } + + #[Test] + public function isCurrentStopsAtFirstDecisiveVoter(): void + { + $decisive = $this->makeVoter(true); + $neverCalled = $this->createMock(VoterInterface::class); + $neverCalled->expects(self::never())->method('matchItem'); + + $this->matcher->addVoters([$decisive, $neverCalled]); + $this->matcher->isCurrent(new Item('home')); + } + + #[Test] + public function isCurrentCachesResultAndCallsVoterOnlyOnce(): void + { + $voter = $this->createMock(VoterInterface::class); + $voter->expects(self::once())->method('matchItem')->willReturn(true); + $this->matcher->addVoters([$voter]); + + $item = new Item('home'); + self::assertTrue($this->matcher->isCurrent($item)); + self::assertTrue($this->matcher->isCurrent($item)); + } + + #[Test] + public function clearInvalidatesCacheAndReVotes(): void + { + $voter = $this->createMock(VoterInterface::class); + $voter->expects(self::exactly(2))->method('matchItem')->willReturn(false); + $this->matcher->addVoters([$voter]); + + $item = new Item('home'); + $this->matcher->isCurrent($item); + $this->matcher->clear(); + $this->matcher->isCurrent($item); // voter called again + } + + #[Test] + public function isAncestorReturnsFalseWhenNoChildIsCurrent(): void + { + $this->matcher->addVoters([$this->makeVoter(null)]); + + $parent = new Item('parent'); + $parent->add(new Item('child')); + + self::assertFalse($this->matcher->isAncestor($parent)); + } + + #[Test] + public function isAncestorReturnsTrueWhenDirectChildIsCurrent(): void + { + $child = new Item('child'); + $parent = new Item('parent'); + $parent->add($child); + + $voter = $this->createStub(VoterInterface::class); + $voter->method('matchItem') + ->willReturnCallback(static fn(ItemInterface $item) => $item === $child ? true : null); + $this->matcher->addVoters([$voter]); + + self::assertTrue($this->matcher->isAncestor($parent)); + } + + #[Test] + public function isAncestorReturnsFalseWithDepthZero(): void + { + $child = new Item('child'); + $parent = new Item('parent'); + $parent->add($child); + $this->matcher->addVoters([$this->makeVoter(true)]); + + self::assertFalse($this->matcher->isAncestor($parent, depth: 0)); + } + + #[Test] + public function isAncestorRespectsDepthLimit(): void + { + $grandchild = new Item('grandchild'); + $child = new Item('child'); + $child->add($grandchild); + $parent = new Item('parent'); + $parent->add($child); + + $voter = $this->createStub(VoterInterface::class); + $voter->method('matchItem') + ->willReturnCallback(static fn(ItemInterface $item) => $item === $grandchild ? true : null); + $this->matcher->addVoters([$voter]); + + self::assertFalse($this->matcher->isAncestor($parent, depth: 1)); + $this->matcher->clear(); + self::assertTrue($this->matcher->isAncestor($parent, depth: 2)); + } + + #[Test] + public function isAncestorIsTrueForGrandchildWithUnlimitedDepth(): void + { + $grandchild = new Item('grandchild'); + $child = new Item('child'); + $child->add($grandchild); + $parent = new Item('parent'); + $parent->add($child); + + $voter = $this->createStub(VoterInterface::class); + $voter->method('matchItem') + ->willReturnCallback(static fn(ItemInterface $item) => $item === $grandchild ? true : null); + $this->matcher->addVoters([$voter]); + + self::assertTrue($this->matcher->isAncestor($parent)); + } + + private function makeVoter(?bool $result): VoterInterface + { + $voter = $this->createStub(VoterInterface::class); + $voter->method('matchItem')->willReturn($result); + + return $voter; + } +} diff --git a/tests/Unit/Matcher/Voter/RouteVoterTest.php b/tests/Unit/Matcher/Voter/RouteVoterTest.php new file mode 100644 index 0000000..3e2e977 --- /dev/null +++ b/tests/Unit/Matcher/Voter/RouteVoterTest.php @@ -0,0 +1,165 @@ +stack = new RequestStack(); + $this->voter = new RouteVoter($this->stack); + } + + #[Test] + public function matchItemReturnsNullWhenNoRequest(): void + { + $item = new Item('home', ['routes' => [['route' => 'app_home']]]); + + self::assertNull($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemReturnsNullWhenRequestHasNoRoute(): void + { + $this->stack->push(new Request()); + + self::assertNull($this->voter->matchItem(new Item('home', ['routes' => [['route' => 'app_home']]]))); + } + + #[Test] + public function matchItemReturnsNullWhenItemHasNoRoutes(): void + { + $this->stack->push($this->makeRequest('app_home')); + + self::assertNull($this->voter->matchItem(new Item('home'))); + } + + #[Test] + public function matchItemReturnsNullWhenItemHasEmptyRoutes(): void + { + $this->stack->push($this->makeRequest('app_home')); + + self::assertNull($this->voter->matchItem(new Item('home', ['routes' => []]))); + } + + #[Test] + public function matchItemReturnsTrueOnExactRouteMatch(): void + { + $this->stack->push($this->makeRequest('app_home')); + $item = new Item('home', ['routes' => [['route' => 'app_home']]]); + + self::assertTrue($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemReturnsNullWhenRouteDoesNotMatch(): void + { + $this->stack->push($this->makeRequest('app_about')); + $item = new Item('home', ['routes' => [['route' => 'app_home']]]); + + self::assertNull($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemSupportsStringShorthand(): void + { + $this->stack->push($this->makeRequest('app_home')); + $item = new Item('home', ['routes' => ['app_home']]); + + self::assertTrue($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemSupportsRegexPattern(): void + { + $this->stack->push($this->makeRequest('app_blog_post')); + $item = new Item('blog', ['routes' => [['route' => 'app_blog_.+']]]); + + self::assertTrue($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemRegexDoesNotMatchPartially(): void + { + $this->stack->push($this->makeRequest('app_blog_post_extra')); + // Anchors ^…$ make this exact: 'app_blog' won't match 'app_blog_post_extra' + $item = new Item('blog', ['routes' => [['route' => 'app_blog']]]); + + self::assertNull($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemChecksRouteParamsForMatch(): void + { + $request = $this->makeRequest('app_post', ['_route_params' => ['id' => '42']]); + $this->stack->push($request); + + $item = new Item('post', ['routes' => [['route' => 'app_post', 'route_params' => ['id' => '42']]]]); + + self::assertTrue($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemReturnsNullWhenRouteParamsDiffer(): void + { + $request = $this->makeRequest('app_post', ['_route_params' => ['id' => '42']]); + $this->stack->push($request); + + $item = new Item('post', ['routes' => [['route' => 'app_post', 'route_params' => ['id' => '99']]]]); + + self::assertNull($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemReturnsTrueOnFirstMatchingRouteInList(): void + { + $this->stack->push($this->makeRequest('app_about')); + $item = new Item('nav', ['routes' => [['route' => 'app_home'], ['route' => 'app_about']]]); + + self::assertTrue($this->voter->matchItem($item)); + } + + #[Test] + public function matchItemThrowsForNonStringNonArrayRouteEntry(): void + { + $this->stack->push($this->makeRequest('app_home')); + $item = new Item('home', ['routes' => [42]]); + + $this->expectException(InvalidArgumentException::class); + $this->voter->matchItem($item); + } + + #[Test] + public function matchItemThrowsForArrayRouteEntryMissingRouteKey(): void + { + $this->stack->push($this->makeRequest('app_home')); + $item = new Item('home', ['routes' => [['route_params' => ['id' => 1]]]]); + + $this->expectException(InvalidArgumentException::class); + $this->voter->matchItem($item); + } + + private function makeRequest(string $route, array $extraAttributes = []): Request + { + $request = new Request(); + $request->attributes->set('_route', $route); + foreach ($extraAttributes as $key => $value) { + $request->attributes->set($key, $value); + } + + return $request; + } +} diff --git a/tests/Unit/Menu/ItemTest.php b/tests/Unit/Menu/ItemTest.php new file mode 100644 index 0000000..6a7474e --- /dev/null +++ b/tests/Unit/Menu/ItemTest.php @@ -0,0 +1,212 @@ +getName()); + } + + #[Test] + public function getLabelDefaultsToEmpty(): void + { + self::assertSame('', (new Item('home'))->getLabel()); + } + + #[Test] + public function getLabel(): void + { + self::assertSame('Home', (new Item('home', ['label' => 'Home']))->getLabel()); + } + + #[Test] + public function getUriDefaultsToNull(): void + { + self::assertNull((new Item('home'))->getUri()); + } + + #[Test] + public function getUri(): void + { + self::assertSame('/home', (new Item('home', ['uri' => '/home']))->getUri()); + } + + #[Test] + public function getRolesDefaultsToEmpty(): void + { + self::assertSame([], (new Item('home'))->getRoles()); + } + + #[Test] + public function getRoles(): void + { + self::assertSame(['ROLE_ADMIN'], (new Item('home', ['roles' => ['ROLE_ADMIN']]))->getRoles()); + } + + #[Test] + public function getOptionReturnsFallbackForMissingKey(): void + { + self::assertSame('default', (new Item('home'))->getOption('missing', 'default')); + } + + #[Test] + public function getOptionReturnsFallbackWhenKeyMissingAndDefaultIsNull(): void + { + self::assertNull((new Item('home'))->getOption('missing')); + } + + #[Test] + public function getOptionReturnsFalseWhenStoredAsFalse(): void + { + // array_key_exists check must distinguish false from missing + $item = new Item('home', ['flag' => false]); + self::assertFalse($item->getOption('flag', 'should-not-use-this')); + } + + #[Test] + public function isSectionFalseByDefault(): void + { + self::assertFalse((new Item('home'))->isSection()); + } + + #[Test] + public function isSection(): void + { + self::assertTrue((new Item('section', [], true))->isSection()); + } + + #[Test] + public function addAppendsAndReturnsSelf(): void + { + $item = new Item('root'); + $child = new Item('child'); + + $result = $item->add($child); + + self::assertSame($item, $result); + self::assertCount(1, $item); + self::assertSame($child, $item->getFirstChild()); + } + + #[Test] + public function addAppendsByDefault(): void + { + $item = new Item('root'); + $first = new Item('first'); + $second = new Item('second'); + $item->add($first); + $item->add($second); + + self::assertSame($first, $item->getFirstChild()); + self::assertSame($second, $item->getLastChild()); + } + + #[Test] + public function addWithPrependInsertsAtFront(): void + { + $item = new Item('root'); + $item->add(new Item('a')); + $item->add(new Item('b'), prepend: true); + + self::assertSame('b', $item->getFirstChild()->getName()); + self::assertSame('a', $item->getLastChild()->getName()); + } + + #[Test] + public function getFirstChildReturnsNullForEmpty(): void + { + self::assertNull((new Item('root'))->getFirstChild()); + } + + #[Test] + public function getLastChildReturnsNullForEmpty(): void + { + self::assertNull((new Item('root'))->getLastChild()); + } + + #[Test] + public function countReflectsChildren(): void + { + $item = new Item('root'); + self::assertSame(0, $item->count()); + $item->add(new Item('a')); + $item->add(new Item('b')); + self::assertSame(2, $item->count()); + } + + #[Test] + public function getChildrenReturnsDoctrineCollection(): void + { + self::assertInstanceOf(Collection::class, (new Item('root'))->getChildren()); + } + + #[Test] + public function isIterableViaForeach(): void + { + $item = new Item('root'); + $child = new Item('child'); + $item->add($child); + + $collected = []; + foreach ($item as $c) { + $collected[] = $c; + } + + self::assertSame([$child], $collected); + } + + #[Test] + public function serializationRoundTripPreservesAllFields(): void + { + $original = new Item('root', ['label' => 'Root', 'roles' => ['ROLE_ADMIN']], true); + $child = new Item('child', ['label' => 'Child']); + $original->add($child); + + /** @var Item $restored */ + $restored = \unserialize(\serialize($original)); + + self::assertSame('root', $restored->getName()); + self::assertSame('Root', $restored->getLabel()); + self::assertTrue($restored->isSection()); + self::assertSame(['ROLE_ADMIN'], $restored->getRoles()); + self::assertCount(1, $restored); + self::assertSame('child', $restored->getFirstChild()->getName()); + self::assertSame('Child', $restored->getFirstChild()->getLabel()); + } + + #[Test] + public function serializationPreservesSectionFalse(): void + { + $item = new Item('item', [], false); + $restored = \unserialize(\serialize($item)); + + self::assertFalse($restored->isSection()); + } + + #[Test] + public function serializationPreservesNestedChildren(): void + { + $root = new Item('root'); + $child = new Item('child'); + $grandchild = new Item('grandchild'); + $child->add($grandchild); + $root->add($child); + + /** @var Item $restored */ + $restored = \unserialize(\serialize($root)); + + self::assertCount(1, $restored); + self::assertCount(1, $restored->getFirstChild()); + self::assertSame('grandchild', $restored->getFirstChild()->getFirstChild()->getName()); + } +} diff --git a/tests/Unit/Menu/MenuBuilderTest.php b/tests/Unit/Menu/MenuBuilderTest.php new file mode 100644 index 0000000..b94e7aa --- /dev/null +++ b/tests/Unit/Menu/MenuBuilderTest.php @@ -0,0 +1,186 @@ +builder = new MenuBuilder(new Factory()); + } + + #[Test] + public function buildReturnsRootItem(): void + { + $root = $this->builder->build(); + + self::assertSame('root', $root->getName()); + self::assertCount(0, $root); + } + + #[Test] + public function addCreatesChildOnRoot(): void + { + $this->builder->add('home'); + + $root = $this->builder->build(); + + self::assertCount(1, $root); + self::assertSame('home', $root->getFirstChild()->getName()); + } + + #[Test] + public function addReturnsSelf(): void + { + self::assertSame($this->builder, $this->builder->add('home')); + } + + #[Test] + public function addPassesOptions(): void + { + $this->builder->add('home', ['label' => 'Home', 'uri' => '/']); + + $item = $this->builder->build()->getFirstChild(); + + self::assertSame('Home', $item->getLabel()); + self::assertSame('/', $item->getUri()); + } + + #[Test] + public function addMultipleItemsInOrder(): void + { + $this->builder->add('a')->add('b')->add('c'); + + $root = $this->builder->build(); + + self::assertCount(3, $root); + self::assertSame('a', $root->getFirstChild()->getName()); + self::assertSame('c', $root->getLastChild()->getName()); + } + + #[Test] + public function addWithPrependInsertsAtFront(): void + { + $this->builder->add('first'); + $this->builder->add('prepended', prepend: true); + + $root = $this->builder->build(); + + self::assertSame('prepended', $root->getFirstChild()->getName()); + self::assertSame('first', $root->getLastChild()->getName()); + } + + #[Test] + public function addWithSectionFlagCreatesSection(): void + { + $this->builder->add('section', section: true); + + self::assertTrue($this->builder->build()->getFirstChild()->isSection()); + } + + #[Test] + public function childrenDescendsIntoLastAddedItem(): void + { + $this->builder + ->add('parent') + ->children() + ->add('child') + ->end(); + + $parent = $this->builder->build()->getFirstChild(); + + self::assertCount(1, $parent); + self::assertSame('child', $parent->getFirstChild()->getName()); + } + + #[Test] + public function childrenReturnsSelf(): void + { + $this->builder->add('parent'); + + self::assertSame($this->builder, $this->builder->children()); + } + + #[Test] + public function endReturnsSelf(): void + { + $this->builder->add('parent')->children(); + + self::assertSame($this->builder, $this->builder->end()); + } + + #[Test] + public function childrenThrowsWhenCurrentHasNoChildren(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/no children/i'); + + $this->builder->children(); + } + + #[Test] + public function endThrowsWhenAlreadyAtRootLevel(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/root level/i'); + + $this->builder->end(); + } + + #[Test] + public function deeplyNestedStructureBuildsCorrectly(): void + { + $this->builder + ->add('a') + ->children() + ->add('a1') + ->add('a2') + ->children() + ->add('a2_1') + ->end() + ->end() + ->add('b'); + + $root = $this->builder->build(); + + self::assertCount(2, $root); + + $a = $root->getFirstChild(); + self::assertSame('a', $a->getName()); + self::assertCount(2, $a); + + $a2 = $a->getLastChild(); + self::assertSame('a2', $a2->getName()); + self::assertCount(1, $a2); + self::assertSame('a2_1', $a2->getFirstChild()->getName()); + + self::assertSame('b', $root->getLastChild()->getName()); + } + + #[Test] + public function childrenFollowsLastAddedItemAfterPrepend(): void + { + // After add(prepend:true), children() should still descend into getLastChild() + $this->builder->add('a'); + $this->builder->add('b', prepend: true); + // last child is 'a', not 'b' + $this->builder->children()->add('a_child')->end(); + + $root = $this->builder->build(); + $a = $root->getLastChild(); // 'a' is last + + self::assertSame('a', $a->getName()); + self::assertCount(1, $a); + self::assertSame('a_child', $a->getFirstChild()->getName()); + } +} diff --git a/tests/Unit/Navigation/AbstractCachedNavigationTest.php b/tests/Unit/Navigation/AbstractCachedNavigationTest.php new file mode 100644 index 0000000..98156c8 --- /dev/null +++ b/tests/Unit/Navigation/AbstractCachedNavigationTest.php @@ -0,0 +1,100 @@ +makeNav(); + + self::assertSame($nav::class, $nav->getCacheKey()); + } + + #[Test] + public function getCacheBetaReturnsZero(): void + { + self::assertSame(0.0, $this->makeNav()->getCacheBeta()); + } + + #[Test] + public function defaultLifetimeIs24Hours(): void + { + $item = $this->createMock(ItemInterface::class); + $item->expects(self::once())->method('expiresAfter')->with(24 * 60 * 60); + $item->method('tag'); + + $this->makeNav()->configureCacheItem($item); + } + + #[Test] + public function configureCacheItemAppliesDefaultNavigationTag(): void + { + $item = $this->createMock(ItemInterface::class); + $item->method('expiresAfter'); + $item->expects(self::once())->method('tag')->with(['navigation']); + + $this->makeNav()->configureCacheItem($item); + } + + #[Test] + public function customLifetimeIsApplied(): void + { + $item = $this->createMock(ItemInterface::class); + $item->expects(self::once())->method('expiresAfter')->with(3600); + $item->method('tag'); + + $this->makeNav(['lifetime' => 3600])->configureCacheItem($item); + } + + #[Test] + public function customTagsAreApplied(): void + { + $item = $this->createMock(ItemInterface::class); + $item->method('expiresAfter'); + $item->expects(self::once())->method('tag')->with(['menu', 'sidebar']); + + $this->makeNav(['tags' => ['menu', 'sidebar']])->configureCacheItem($item); + } + + #[Test] + public function emptyTagsSkipsTagCall(): void + { + $item = $this->createMock(ItemInterface::class); + $item->method('expiresAfter'); + $item->expects(self::never())->method('tag'); + + $this->makeNav(['tags' => []])->configureCacheItem($item); + } + + #[Test] + public function customOptionsAreMergedWithDefaults(): void + { + // Providing only lifetime should not reset tags to empty + $nav = $this->makeNav(['lifetime' => 7200]); + + $item = $this->createMock(ItemInterface::class); + $item->expects(self::once())->method('expiresAfter')->with(7200); + $item->expects(self::once())->method('tag')->with(['navigation']); // default tags preserved + + $nav->configureCacheItem($item); + } + + private function makeNav(array $cacheOptions = []): AbstractCachedNavigation + { + return new class($cacheOptions) extends AbstractCachedNavigation { + public function build(MenuBuilderInterface $builder, array $options = []): void + { + } + }; + } +} diff --git a/tests/Unit/Navigation/ClosureNavigationTest.php b/tests/Unit/Navigation/ClosureNavigationTest.php new file mode 100644 index 0000000..3cbbcdc --- /dev/null +++ b/tests/Unit/Navigation/ClosureNavigationTest.php @@ -0,0 +1,51 @@ +createStub(MenuBuilderInterface::class); + $nav->build($builder, ['locale' => 'ru']); + + self::assertTrue($called); + self::assertSame($builder, $receivedBuilder); + self::assertSame(['locale' => 'ru'], $receivedOptions); + } + + #[Test] + public function buildPassesEmptyOptionsWhenNoneGiven(): void + { + $received = null; + $nav = new ClosureNavigation( + function (MenuBuilderInterface $b, array $options) use (&$received): void { + $received = $options; + } + ); + + $nav->build($this->createStub(MenuBuilderInterface::class)); + + self::assertSame([], $received); + } +}