---
Build/phpstan.neon | 34 +-
.../Context/Type/CombinationContextTest.php | 702 ++++++++++
.../ContextConditionProviderTest.php | 136 ++
.../ContextFunctionsProviderTest.php | 267 ++++
.../Form/CombinationFormElementTest.php | 506 ++++++++
.../Form/DefaultSettingsFormElementTest.php | 530 ++++++++
.../Form/RecordSettingsFormElementTest.php | 999 +++++++++++++++
.../ContainerInitializationTest.php | 293 +++++
.../Service/DataHandlerServiceTest.php | 1131 +++++++++++++++++
.../Classes/Service/InstallServiceTest.php | 356 ++++++
10 files changed, 4952 insertions(+), 2 deletions(-)
create mode 100644 Tests/Unit/Classes/Context/Type/CombinationContextTest.php
create mode 100644 Tests/Unit/Classes/ExpressionLanguage/ContextConditionProviderTest.php
create mode 100644 Tests/Unit/Classes/ExpressionLanguage/FunctionsProvider/ContextFunctionsProviderTest.php
create mode 100644 Tests/Unit/Classes/Form/CombinationFormElementTest.php
create mode 100644 Tests/Unit/Classes/Form/DefaultSettingsFormElementTest.php
create mode 100644 Tests/Unit/Classes/Form/RecordSettingsFormElementTest.php
create mode 100644 Tests/Unit/Classes/Middleware/ContainerInitializationTest.php
create mode 100644 Tests/Unit/Classes/Service/DataHandlerServiceTest.php
create mode 100644 Tests/Unit/Classes/Service/InstallServiceTest.php
diff --git a/Build/phpstan.neon b/Build/phpstan.neon
index 03096f3..9b20dae 100644
--- a/Build/phpstan.neon
+++ b/Build/phpstan.neon
@@ -203,9 +203,39 @@ parameters:
paths:
- ../Tests/*
- # PHPUnit assertion parameter types (mixed arrays in tests)
+ # PHPUnit assertion parameter types (mixed values in tests)
-
- message: '#Parameter \#2 \$.* of static method PHPUnit\\Framework\\Assert::(assertArrayHasKey|assertArrayNotHasKey|assertContains)\(\) expects#'
+ message: '#Parameter \#\d+ \$.* of static method PHPUnit\\Framework\\Assert::\w+\(\) expects .*, mixed given#'
+ paths:
+ - ../Tests/*
+
+ # Binary operations on mixed types in tests
+ -
+ message: '#Binary operation .* between .* and mixed results in an error#'
+ paths:
+ - ../Tests/*
+
+ # Method calls on potentially null values in tests
+ -
+ message: '#Cannot call method .* on .*\|null#'
+ paths:
+ - ../Tests/*
+
+ # isset() on non-nullable properties in test stubs
+ -
+ message: '#Property .* in isset\(\) is not nullable#'
+ paths:
+ - ../Tests/*
+
+ # Encapsed string part type issues in tests
+ -
+ message: '#Part .* of encapsed string cannot be cast to string#'
+ paths:
+ - ../Tests/*
+
+ # Mixed type argument passing in test stubs/helpers
+ -
+ message: '#expects string, mixed given#'
paths:
- ../Tests/*
diff --git a/Tests/Unit/Classes/Context/Type/CombinationContextTest.php b/Tests/Unit/Classes/Context/Type/CombinationContextTest.php
new file mode 100644
index 0000000..f7fb384
--- /dev/null
+++ b/Tests/Unit/Classes/Context/Type/CombinationContextTest.php
@@ -0,0 +1,702 @@
+
+ * [label => [expression, ctx1Matched, ctx2Matched, expectedResult]]
+ */
+ public static function logicalExpressionProvider(): array
+ {
+ return [
+ 'AND both true' => ['ctx1 && ctx2', true, true, true],
+ 'AND first false' => ['ctx1 && ctx2', false, true, false],
+ 'AND second false' => ['ctx1 && ctx2', true, false, false],
+ 'AND both false' => ['ctx1 && ctx2', false, false, false],
+ 'OR both true' => ['ctx1 || ctx2', true, true, true],
+ 'OR first true' => ['ctx1 || ctx2', true, false, true],
+ 'OR second true' => ['ctx1 || ctx2', false, true, true],
+ 'OR both false' => ['ctx1 || ctx2', false, false, false],
+ 'XOR only first true' => ['ctx1 >< ctx2', true, false, true],
+ 'XOR only second true' => ['ctx1 >< ctx2', false, true, true],
+ 'XOR both true' => ['ctx1 >< ctx2', true, true, false],
+ 'XOR both false' => ['ctx1 >< ctx2', false, false, false],
+ ];
+ }
+ // -----------------------------------------------------------------------
+ // getDependencies() tests
+ // -----------------------------------------------------------------------
+
+ #[Test]
+ public function getDependenciesReturnsEmptyArrayWhenExpressionHasNoVariables(): void
+ {
+ $combinationContext = $this->createCombinationContext(10, 'combi', '');
+
+ $dependencies = $combinationContext->getDependencies([]);
+
+ self::assertSame([], $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesReturnsEmptyArrayWhenNoContextMatchesAlias(): void
+ {
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 && ctx2');
+
+ // No contexts passed - none will match the aliases
+ $dependencies = $combinationContext->getDependencies([]);
+
+ self::assertSame([], $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesReturnsTrueForEnabledMatchingContext(): void
+ {
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1');
+
+ $dependencies = $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ self::assertSame([1 => true], $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesReturnsFalseForDisabledMatchingContext(): void
+ {
+ $ctx = $this->createTestContext(1, 'ctx1', true);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1');
+
+ $dependencies = $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ self::assertSame([1 => false], $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesCollectsMultipleDistinctContexts(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 && ctx2');
+
+ $dependencies = $combinationContext->getDependencies([
+ 1 => $ctx1,
+ 2 => $ctx2,
+ 10 => $combinationContext,
+ ]);
+
+ self::assertSame([1 => true, 2 => true], $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesDeduplicatesRepeatedAliasInExpression(): void
+ {
+ // "ctx1 && ctx1 || ctx1" references the same alias three times; uid 1 should appear once.
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 && ctx1 || ctx1');
+
+ $dependencies = $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ self::assertCount(1, $dependencies);
+ self::assertArrayHasKey(1, $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesIgnoresAliasesNotFoundInContextList(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ // ctx2 is referenced in the expression but not in the context list
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 && ctx2');
+
+ $dependencies = $combinationContext->getDependencies([1 => $ctx1, 10 => $combinationContext]);
+
+ // Only ctx1 (uid 1) should be resolved
+ self::assertSame([1 => true], $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesHandlesMixedEnabledAndDisabledContexts(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false); // enabled
+ $ctx2 = $this->createTestContext(2, 'ctx2', true); // disabled
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 && ctx2');
+
+ $dependencies = $combinationContext->getDependencies([
+ 1 => $ctx1,
+ 2 => $ctx2,
+ 10 => $combinationContext,
+ ]);
+
+ self::assertSame([1 => true, 2 => false], $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesAliasComparisonIsCaseInsensitive(): void
+ {
+ // AbstractContext::getAlias() calls strtolower(), so "CTX1" in the
+ // context row resolves to "ctx1". The expression uses "CTX1".
+ $ctx = $this->createTestContext(1, 'CTX1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'CTX1');
+
+ $dependencies = $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ self::assertArrayHasKey(1, $dependencies);
+ }
+
+ #[Test]
+ public function getDependenciesHandlesComplexExpressionWithParentheses(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $ctx3 = $this->createTestContext(3, 'ctx3', true);
+ $combinationContext = $this->createCombinationContext(10, 'combi', '(ctx1 && ctx2) || !ctx3');
+
+ $dependencies = $combinationContext->getDependencies([
+ 1 => $ctx1,
+ 2 => $ctx2,
+ 3 => $ctx3,
+ 10 => $combinationContext,
+ ]);
+
+ self::assertSame([1 => true, 2 => true, 3 => false], $dependencies);
+ }
+
+ // -----------------------------------------------------------------------
+ // match() tests — using crafted dependency stdClass objects
+ // as Container passes them (see Container::match() lines 218/225/229)
+ // -----------------------------------------------------------------------
+
+ #[Test]
+ public function matchReturnsTrueForSingleMatchedDependency(): void
+ {
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1');
+
+ // First call getDependencies so the evaluator and tokens are initialised
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = true;
+
+ self::assertTrue($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ public function matchReturnsFalseForSingleUnmatchedDependency(): void
+ {
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1');
+
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = false;
+
+ self::assertFalse($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ public function matchReturnsTrueForAndExpressionWhenBothMatch(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 && ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = true;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = true;
+
+ self::assertTrue($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchReturnsFalseForAndExpressionWhenOneDoesNotMatch(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 && ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = true;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = false;
+
+ self::assertFalse($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchReturnsTrueForOrExpressionWhenOnlyOneMatches(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 || ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = false;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = true;
+
+ self::assertTrue($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchReturnsFalseForOrExpressionWhenBothFail(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 || ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = false;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = false;
+
+ self::assertFalse($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchReturnsTrueForXorExpressionWhenExactlyOneMatches(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 >< ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = true;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = false;
+
+ self::assertTrue($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchReturnsFalseForXorExpressionWhenBothMatch(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 >< ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = true;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = true;
+
+ self::assertFalse($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchReturnsFalseForXorExpressionWhenBothFail(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 >< ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = false;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = false;
+
+ self::assertFalse($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchReturnsTrueForNegatedUnmatchedDependency(): void
+ {
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', '!ctx1');
+
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = false;
+
+ // !false = true
+ self::assertTrue($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ public function matchReturnsFalseForNegatedMatchedDependency(): void
+ {
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', '!ctx1');
+
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = true;
+
+ // !true = false
+ self::assertFalse($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ public function matchInvertsResultWhenInvertFlagIsSet(): void
+ {
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ // Expression evaluates to true, but invert=true flips it
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1', true);
+
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = true;
+
+ self::assertFalse($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ public function matchInvertedReturnsTrueWhenExpressionIsFalseAndInvertIsSet(): void
+ {
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1', true);
+
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = false;
+
+ self::assertTrue($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ public function matchUsesUidWhenDependencyContextHasEmptyAlias(): void
+ {
+ // When context alias is empty, match() still stores uid => matched.
+ // The evaluator then looks up the variable by its name in the expression.
+ // Because the expression uses the alias name, the result will default to
+ // true (unknown variable), so the match should return true.
+ $ctx = $this->createTestContext(1, '', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1');
+
+ // ctx1 alias does not exist, so getDependencies returns nothing
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = false;
+
+ // The evaluator has "ctx1" as a variable, but no value for it; defaults to true
+ self::assertTrue($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ public function matchHandlesNestedParenthesesExpression(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $ctx3 = $this->createTestContext(3, 'ctx3', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', '(ctx1 && ctx2) || ctx3');
+
+ $combinationContext->getDependencies([
+ 1 => $ctx1,
+ 2 => $ctx2,
+ 3 => $ctx3,
+ 10 => $combinationContext,
+ ]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = true;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = false;
+
+ $dep3 = new stdClass();
+ $dep3->context = $ctx3;
+ $dep3->matched = true;
+
+ // (true && false) || true = false || true = true
+ self::assertTrue($combinationContext->match([1 => $dep1, 2 => $dep2, 3 => $dep3]));
+ }
+
+ #[Test]
+ public function matchHandlesNestedParenthesesExpressionFalse(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $ctx3 = $this->createTestContext(3, 'ctx3', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', '(ctx1 && ctx2) || ctx3');
+
+ $combinationContext->getDependencies([
+ 1 => $ctx1,
+ 2 => $ctx2,
+ 3 => $ctx3,
+ 10 => $combinationContext,
+ ]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = true;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = false;
+
+ $dep3 = new stdClass();
+ $dep3->context = $ctx3;
+ $dep3->matched = false;
+
+ // (true && false) || false = false || false = false
+ self::assertFalse($combinationContext->match([1 => $dep1, 2 => $dep2, 3 => $dep3]));
+ }
+
+ #[Test]
+ public function matchHandlesDisabledDependencyTreatedAsMatching(): void
+ {
+ // When a dependency is disabled, Container passes matched='disabled'.
+ // The evaluator treats 'disabled' as true.
+ $ctx = $this->createTestContext(1, 'ctx1', true);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1');
+
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = 'disabled';
+
+ // 'disabled' is treated as true in the evaluator
+ self::assertTrue($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ public function matchHandlesWordOperatorsAndOrXor(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ // The tokenizer replaces "and" -> "&&", "or" -> "||", "xor" -> "><"
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 and ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = true;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = true;
+
+ self::assertTrue($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchHandlesWordOrOperator(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 or ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = false;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = true;
+
+ self::assertTrue($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchHandlesWordXorOperator(): void
+ {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1 xor ctx2');
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = true;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = false;
+
+ self::assertTrue($combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ #[Test]
+ public function matchUsesAliasKeyWhenContextHasAlias(): void
+ {
+ // Context with alias 'ctx1': match() stores both alias and uid
+ // in the values array. The evaluator finds the alias key.
+ $ctx = $this->createTestContext(1, 'ctx1', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', 'ctx1');
+
+ $combinationContext->getDependencies([1 => $ctx, 10 => $combinationContext]);
+
+ $dep = new stdClass();
+ $dep->context = $ctx;
+ $dep->matched = true;
+
+ self::assertTrue($combinationContext->match([1 => $dep]));
+ }
+
+ #[Test]
+ #[DataProvider('logicalExpressionProvider')]
+ public function matchEvaluatesLogicalExpressionsCorrectly(
+ string $expression,
+ bool $ctx1Matched,
+ bool $ctx2Matched,
+ bool $expectedResult,
+ ): void {
+ $ctx1 = $this->createTestContext(1, 'ctx1', false);
+ $ctx2 = $this->createTestContext(2, 'ctx2', false);
+ $combinationContext = $this->createCombinationContext(10, 'combi', $expression);
+
+ $combinationContext->getDependencies([1 => $ctx1, 2 => $ctx2, 10 => $combinationContext]);
+
+ $dep1 = new stdClass();
+ $dep1->context = $ctx1;
+ $dep1->matched = $ctx1Matched;
+
+ $dep2 = new stdClass();
+ $dep2->context = $ctx2;
+ $dep2->matched = $ctx2Matched;
+
+ self::assertSame($expectedResult, $combinationContext->match([1 => $dep1, 2 => $dep2]));
+ }
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ /**
+ * Create an anonymous AbstractContext stub with the given properties.
+ */
+ private function createTestContext(
+ int $uid,
+ string $alias,
+ bool $disabled,
+ ): AbstractContext {
+ return new class ($uid, $alias, $disabled) extends AbstractContext {
+ public function __construct(int $uid, string $alias, bool $disabled)
+ {
+ parent::__construct();
+ $this->uid = $uid;
+ $this->alias = $alias;
+ $this->disabled = $disabled;
+ }
+
+ public function match(array $arDependencies = []): bool
+ {
+ return false;
+ }
+ };
+ }
+
+ /**
+ * Create a CombinationContext stub that returns the given expression
+ * from getConfValue('field_expression').
+ */
+ private function createCombinationContext(
+ int $uid,
+ string $alias,
+ string $expression,
+ bool $invert = false,
+ ): CombinationContext {
+ return new class ($uid, $alias, $expression, $invert) extends CombinationContext {
+ private readonly string $expression;
+
+ public function __construct(int $uid, string $alias, string $expression, bool $invert)
+ {
+ parent::__construct();
+ $this->uid = $uid;
+ $this->alias = $alias;
+ $this->disabled = false;
+ $this->expression = $expression;
+ $this->invert = $invert;
+ }
+
+ protected function getConfValue(
+ string $fieldName,
+ string $default = '',
+ string $sheet = 'sDEF',
+ string $lang = 'lDEF',
+ string $value = 'vDEF',
+ ): string {
+ if ($fieldName === 'field_expression') {
+ return $this->expression;
+ }
+
+ return $default;
+ }
+ };
+ }
+}
diff --git a/Tests/Unit/Classes/ExpressionLanguage/ContextConditionProviderTest.php b/Tests/Unit/Classes/ExpressionLanguage/ContextConditionProviderTest.php
new file mode 100644
index 0000000..d4ded48
--- /dev/null
+++ b/Tests/Unit/Classes/ExpressionLanguage/ContextConditionProviderTest.php
@@ -0,0 +1,136 @@
+getExpressionLanguageProviders();
+
+ self::assertContains(ContextFunctionsProvider::class, $providers);
+ }
+
+ #[Test]
+ public function getExpressionLanguageProvidersReturnsNonEmptyArray(): void
+ {
+ $provider = new ContextConditionProvider();
+
+ $providers = $provider->getExpressionLanguageProviders();
+
+ self::assertNotEmpty($providers);
+ }
+
+ #[Test]
+ public function getExpressionLanguageProvidersContainsExactlyOneEntry(): void
+ {
+ $provider = new ContextConditionProvider();
+
+ $providers = $provider->getExpressionLanguageProviders();
+
+ self::assertCount(1, $providers);
+ }
+
+ #[Test]
+ public function getExpressionLanguageProvidersReturnsListWithContextFunctionsProviderAsFirstEntry(): void
+ {
+ $provider = new ContextConditionProvider();
+
+ $providers = $provider->getExpressionLanguageProviders();
+
+ self::assertSame(ContextFunctionsProvider::class, $providers[0]);
+ }
+
+ // ========================================
+ // Variables — none registered
+ // ========================================
+
+ #[Test]
+ public function getExpressionLanguageVariablesReturnsEmptyArray(): void
+ {
+ $provider = new ContextConditionProvider();
+
+ $variables = $provider->getExpressionLanguageVariables();
+
+ self::assertSame([], $variables);
+ }
+
+ // ========================================
+ // Multiple instantiations are independent
+ // ========================================
+
+ #[Test]
+ public function eachInstanceHasItsOwnProviderList(): void
+ {
+ $providerA = new ContextConditionProvider();
+ $providerB = new ContextConditionProvider();
+
+ self::assertSame(
+ $providerA->getExpressionLanguageProviders(),
+ $providerB->getExpressionLanguageProviders(),
+ );
+ }
+
+ #[Test]
+ public function registeredProviderClassIsInstantiable(): void
+ {
+ $provider = new ContextConditionProvider();
+ $providers = $provider->getExpressionLanguageProviders();
+
+ foreach ($providers as $providerClass) {
+ self::assertTrue(
+ class_exists($providerClass),
+ \sprintf('Provider class "%s" must exist and be autoloadable.', $providerClass),
+ );
+ }
+ }
+}
diff --git a/Tests/Unit/Classes/ExpressionLanguage/FunctionsProvider/ContextFunctionsProviderTest.php b/Tests/Unit/Classes/ExpressionLanguage/FunctionsProvider/ContextFunctionsProviderTest.php
new file mode 100644
index 0000000..dd3c119
--- /dev/null
+++ b/Tests/Unit/Classes/ExpressionLanguage/FunctionsProvider/ContextFunctionsProviderTest.php
@@ -0,0 +1,267 @@
+getFunctions());
+ }
+
+ #[Test]
+ public function getFunctionsReturnsExactlyOneFunction(): void
+ {
+ $provider = new ContextFunctionsProvider();
+
+ self::assertCount(1, $provider->getFunctions());
+ }
+
+ #[Test]
+ public function getFunctionsReturnsOnlyExpressionFunctionInstances(): void
+ {
+ $provider = new ContextFunctionsProvider();
+
+ self::assertContainsOnlyInstancesOf(ExpressionFunction::class, $provider->getFunctions());
+ }
+
+ #[Test]
+ public function getFunctionsProvidesContextMatchFunction(): void
+ {
+ $provider = new ContextFunctionsProvider();
+ $functions = $provider->getFunctions();
+
+ $names = array_map(static fn(ExpressionFunction $f): string => $f->getName(), $functions);
+
+ self::assertContains('contextMatch', $names);
+ }
+
+ // ========================================
+ // contextMatch — compiler callable (no-op)
+ // ========================================
+
+ #[Test]
+ public function contextMatchCompilerCallableIsCallable(): void
+ {
+ $provider = new ContextFunctionsProvider();
+ $functions = $provider->getFunctions();
+
+ $contextMatch = $this->findContextMatchFunction($functions);
+ self::assertNotNull($contextMatch, 'contextMatch function must be provided');
+
+ $compiler = $contextMatch->getCompiler();
+ self::assertIsCallable($compiler);
+ }
+
+ #[Test]
+ public function contextMatchCompilerCallableReturnsNull(): void
+ {
+ $provider = new ContextFunctionsProvider();
+ $contextMatch = $this->findContextMatchFunction($provider->getFunctions());
+
+ self::assertNotNull($contextMatch);
+
+ $compiler = $contextMatch->getCompiler();
+ // The compiler is a no-op static closure that returns void (null)
+ $result = $compiler();
+ self::assertNull($result);
+ }
+
+ // ========================================
+ // contextMatch evaluator — delegates to ContextMatcher
+ // ========================================
+
+ #[Test]
+ public function contextMatchEvaluatorIsCallable(): void
+ {
+ $provider = new ContextFunctionsProvider();
+ $contextMatch = $this->findContextMatchFunction($provider->getFunctions());
+
+ self::assertNotNull($contextMatch);
+ self::assertIsCallable($contextMatch->getEvaluator());
+ }
+
+ #[Test]
+ public function contextMatchEvaluatorReturnsTrueWhenContextIsActive(): void
+ {
+ $mockContext = $this->createMock(AbstractContext::class);
+ $mockContext->method('getAlias')->willReturn('mobile');
+ $mockContext->method('getUid')->willReturn(1);
+
+ Container::get()->exchangeArray([1 => $mockContext]);
+
+ $provider = new ContextFunctionsProvider();
+ $evaluator = $this->findContextMatchFunction($provider->getFunctions())->getEvaluator();
+
+ self::assertTrue($evaluator([], 'mobile'));
+ }
+
+ #[Test]
+ public function contextMatchEvaluatorReturnsFalseWhenContextIsNotActive(): void
+ {
+ Container::get()->exchangeArray([]);
+
+ $provider = new ContextFunctionsProvider();
+ $evaluator = $this->findContextMatchFunction($provider->getFunctions())->getEvaluator();
+
+ self::assertFalse($evaluator([], 'nonexistent'));
+ }
+
+ #[Test]
+ public function contextMatchEvaluatorIsCaseInsensitive(): void
+ {
+ $mockContext = $this->createMock(AbstractContext::class);
+ $mockContext->method('getAlias')->willReturn('desktop');
+ $mockContext->method('getUid')->willReturn(2);
+
+ Container::get()->exchangeArray([2 => $mockContext]);
+
+ $provider = new ContextFunctionsProvider();
+ $evaluator = $this->findContextMatchFunction($provider->getFunctions())->getEvaluator();
+
+ self::assertTrue($evaluator([], 'desktop'));
+ self::assertTrue($evaluator([], 'DESKTOP'));
+ self::assertTrue($evaluator([], 'Desktop'));
+ }
+
+ #[Test]
+ public function contextMatchEvaluatorReturnsFalseForEmptyContextString(): void
+ {
+ $provider = new ContextFunctionsProvider();
+ $evaluator = $this->findContextMatchFunction($provider->getFunctions())->getEvaluator();
+
+ self::assertFalse($evaluator([], ''));
+ }
+
+ #[Test]
+ public function contextMatchEvaluatorMatchesByAlias(): void
+ {
+ $mockContext = $this->createMock(AbstractContext::class);
+ $mockContext->method('getAlias')->willReturn('my-alias');
+ $mockContext->method('getUid')->willReturn(99);
+
+ Container::get()->exchangeArray([99 => $mockContext]);
+
+ $provider = new ContextFunctionsProvider();
+ $evaluator = $this->findContextMatchFunction($provider->getFunctions())->getEvaluator();
+
+ // Alias matches
+ self::assertTrue($evaluator([], 'my-alias'));
+ }
+
+ #[Test]
+ public function contextMatchEvaluatorAlsoMatchesByNumericUid(): void
+ {
+ $mockContext = $this->createMock(AbstractContext::class);
+ $mockContext->method('getAlias')->willReturn('my-alias');
+ $mockContext->method('getUid')->willReturn(99);
+
+ // Container stores context at numeric key 99 — find() uses is_numeric check
+ Container::get()->exchangeArray([99 => $mockContext]);
+
+ $provider = new ContextFunctionsProvider();
+ $evaluator = $this->findContextMatchFunction($provider->getFunctions())->getEvaluator();
+
+ // Container::find() also matches by numeric UID when context is at that key
+ self::assertTrue($evaluator([], '99'));
+ }
+
+ #[Test]
+ public function getFunctionsIsIdempotent(): void
+ {
+ $provider = new ContextFunctionsProvider();
+
+ $firstCall = $provider->getFunctions();
+ $secondCall = $provider->getFunctions();
+
+ self::assertCount(\count($firstCall), $secondCall);
+ self::assertSame($firstCall[0]->getName(), $secondCall[0]->getName());
+ }
+
+ // ========================================
+ // Helpers
+ // ========================================
+
+ /**
+ * @param ExpressionFunction[] $functions
+ */
+ private function findContextMatchFunction(array $functions): ?ExpressionFunction
+ {
+ foreach ($functions as $function) {
+ if ($function->getName() === 'contextMatch') {
+ return $function;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/Tests/Unit/Classes/Form/CombinationFormElementTest.php b/Tests/Unit/Classes/Form/CombinationFormElementTest.php
new file mode 100644
index 0000000..cad0630
--- /dev/null
+++ b/Tests/Unit/Classes/Form/CombinationFormElementTest.php
@@ -0,0 +1,506 @@
+isSubclassOf(AbstractFormElement::class),
+ 'CombinationFormElement must extend AbstractFormElement',
+ );
+ }
+
+ #[Test]
+ public function renderMethodExists(): void
+ {
+ $reflection = new ReflectionClass(CombinationFormElement::class);
+
+ self::assertTrue(
+ $reflection->hasMethod('render'),
+ 'CombinationFormElement must have a render() method',
+ );
+ }
+
+ #[Test]
+ public function renderMethodIsPublic(): void
+ {
+ $reflection = new ReflectionClass(CombinationFormElement::class);
+ $method = $reflection->getMethod('render');
+
+ self::assertTrue($method->isPublic(), 'render() must be public');
+ }
+
+ #[Test]
+ public function renderMethodReturnsArray(): void
+ {
+ $reflection = new ReflectionClass(CombinationFormElement::class);
+ $method = $reflection->getMethod('render');
+ $returnType = $method->getReturnType();
+
+ self::assertNotNull($returnType);
+ self::assertSame('array', $returnType->getName());
+ }
+
+ #[Test]
+ public function classIsNotFinal(): void
+ {
+ $reflection = new ReflectionClass(CombinationFormElement::class);
+
+ self::assertFalse(
+ $reflection->isFinal(),
+ 'CombinationFormElement should not be final to allow extension',
+ );
+ }
+
+ // =========================================================================
+ // Render logic tests via testable subclass
+ // =========================================================================
+
+ #[Test]
+ public function renderReturnsTextElementResultWhenNoTokensPresent(): void
+ {
+ $expectedResult = [
+ 'html' => '',
+ 'additionalHiddenFields' => [],
+ 'additionalInlineLanguageLabelFiles' => [],
+ 'stylesheetFiles' => [],
+ 'javaScriptModules' => [],
+ 'inlineData' => [],
+ ];
+
+ $element = $this->buildTestableElement(
+ itemFormElValue: '',
+ textElementResult: $expectedResult,
+ containerContexts: [],
+ );
+
+ $result = $element->render();
+
+ self::assertSame($expectedResult, $result);
+ }
+
+ #[Test]
+ public function renderReturnsTextElementResultWhenAllAliasesFoundInContainer(): void
+ {
+ $textResult = [
+ 'html' => '',
+ 'additionalHiddenFields' => [],
+ 'additionalInlineLanguageLabelFiles' => [],
+ 'stylesheetFiles' => [],
+ 'javaScriptModules' => [],
+ 'inlineData' => [],
+ ];
+
+ $mockContext = $this->createMock(\Netresearch\Contexts\Context\AbstractContext::class);
+ $mockContext->method('getAlias')->willReturn('mobile');
+
+ $element = $this->buildTestableElement(
+ itemFormElValue: 'mobile',
+ textElementResult: $textResult,
+ containerContexts: [1 => $mockContext],
+ );
+
+ $result = $element->render();
+
+ // All aliases found, so return the plain text element result
+ self::assertSame($textResult, $result);
+ }
+
+ #[Test]
+ public function renderAddsErrorDivWhenAliasNotFoundInContainer(): void
+ {
+ $textResult = [
+ 'html' => '',
+ 'additionalHiddenFields' => [],
+ 'additionalInlineLanguageLabelFiles' => [],
+ 'stylesheetFiles' => [],
+ 'javaScriptModules' => [],
+ 'inlineData' => [],
+ ];
+
+ $element = $this->buildTestableElement(
+ itemFormElValue: 'nonexistent',
+ textElementResult: $textResult,
+ containerContexts: [],
+ notFoundLabel: 'Aliases not found',
+ );
+
+ $result = $element->render();
+
+ self::assertStringContainsString('', $result['html']);
+ self::assertStringContainsString('Aliases not found', $result['html']);
+ self::assertStringContainsString('nonexistent', $result['html']);
+ }
+
+ #[Test]
+ public function renderContainsTextElementHtmlWhenNotFound(): void
+ {
+ $textHtml = '';
+ $textResult = [
+ 'html' => $textHtml,
+ 'additionalHiddenFields' => [],
+ 'additionalInlineLanguageLabelFiles' => [],
+ 'stylesheetFiles' => [],
+ 'javaScriptModules' => [],
+ 'inlineData' => [],
+ ];
+
+ $element = $this->buildTestableElement(
+ itemFormElValue: 'missing-alias',
+ textElementResult: $textResult,
+ containerContexts: [],
+ notFoundLabel: 'Aliases not found',
+ );
+
+ $result = $element->render();
+
+ self::assertStringContainsString($textHtml, $result['html']);
+ }
+
+ #[Test]
+ public function renderEscapesHtmlInNotFoundAliases(): void
+ {
+ $textResult = [
+ 'html' => '',
+ 'additionalHiddenFields' => [],
+ 'additionalInlineLanguageLabelFiles' => [],
+ 'stylesheetFiles' => [],
+ 'javaScriptModules' => [],
+ 'inlineData' => [],
+ ];
+
+ $element = $this->buildTestableElement(
+ itemFormElValue: '',
+ );
+
+ $result = $element->render();
+
+ self::assertStringNotContainsString('',
+ disabled: false,
+ hideInBackend: false,
+ );
+
+ $element = $this->buildTestableElement(
+ uid: 0,
+ tableName: 'tt_content',
+ settings: ['tx_contexts' => ['label' => 'LLL:visibility']],
+ contexts: [1 => $context],
+ );
+
+ $result = $element->render();
+
+ self::assertStringNotContainsString('