From 2a9f873e2288b5ce71f3c4e16671a39389b00bf2 Mon Sep 17 00:00:00 2001 From: "v.razuvaev" Date: Wed, 5 Aug 2026 06:47:08 +0300 Subject: [PATCH] test: add property-based tests for AND/OR/NOT composition laws Fixes #20 --- .../CompositionLawsPropertyTest.php | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 tests/Integration/CompositionLawsPropertyTest.php diff --git a/tests/Integration/CompositionLawsPropertyTest.php b/tests/Integration/CompositionLawsPropertyTest.php new file mode 100644 index 0000000..cc2e4b3 --- /dev/null +++ b/tests/Integration/CompositionLawsPropertyTest.php @@ -0,0 +1,241 @@ +db = new Connection(new Driver('sqlite::memory:'), new SchemaCache(new ArrayCache())); + + $this->db->createCommand( + 'CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT, status TEXT, price INTEGER, created_at TEXT)', + )->execute(); + + $rows = [ + [1, 'alpha', 'active', 10, '2024-01-01'], + [2, 'bravo', 'active', 20, '2024-02-01'], + [3, 'charlie', 'inactive', 30, '2024-03-01'], + [4, 'delta', 'active', 40, '2024-04-01'], + [5, 'echo', 'inactive', 50, '2024-05-01'], + ]; + $this->db->createCommand() + ->insertBatch('items', $rows, ['id', 'name', 'status', 'price', 'created_at']) + ->execute(); + } + + /** + * @return list Matching ids, sorted ascending. + */ + private function ids(Specification $spec): array + { + $query = (new Query($this->db))->select('id')->from('items'); + QueryApplier::apply(specification: $spec, query: $query); + + $ids = array_map(static fn(array|object $row): int => (int) ((array) $row)['id'], $query->all()); + sort($ids); + + return $ids; + } + + /** + * @return list + */ + private static function leaves(): array + { + return [ + ComparisonSpecification::equal(column: 'status', value: 'active'), + ComparisonSpecification::equal(column: 'status', value: 'inactive'), + ComparisonSpecification::greaterThan(column: 'price', value: 20), + ComparisonSpecification::greaterThanOrEqual(column: 'price', value: 20), + ComparisonSpecification::lessThan(column: 'price', value: 40), + ComparisonSpecification::between(column: 'price', from: 20, to: 40), + ComparisonSpecification::startsWith(column: 'name', prefix: 'a'), + ComparisonSpecification::in(column: 'name', values: ['alpha', 'bravo']), + ComparisonSpecification::notEqual(column: 'id', value: 3), + ComparisonSpecification::greaterThanOrEqual(column: 'created_at', value: '2024-03-01'), + ]; + } + + /** + * A leaf comparison, or that comparison wrapped in NOT with equal odds — + * exercises the visitor's subquery/param-isolation path for negated + * operands, not just plain comparisons. + */ + private static function operand(): ArbitraryInterface + { + return Gen::recursive( + leaf: Gen::elements(values: self::leaves()), + wrap: static fn(ArbitraryInterface $inner): ArbitraryInterface => Gen::map( + inner: $inner, + map: static fn(ComparisonSpecification $spec): NotSpecification => NotSpecification::create(specification: $spec), + ), + maxDepth: 1, + ); + } + + #[Property(runs: 150)] + public function andIsCommutative(Specification $a, Specification $b): void + { + $lhs = CompositeSpecification::create()->withSpecification($a)->withSpecification($b); + $rhs = CompositeSpecification::create()->withSpecification($b)->withSpecification($a); + + Assert::same($this->ids($lhs), $this->ids($rhs)); + } + + /** @return array */ + public static function andIsCommutativeGenerators(): array + { + return ['a' => self::operand(), 'b' => self::operand()]; + } + + #[Property(runs: 100)] + public function andIsAssociative(Specification $a, Specification $b, Specification $c): void + { + $lhs = CompositeSpecification::create() + ->withSpecification(CompositeSpecification::create()->withSpecification($a)->withSpecification($b)) + ->withSpecification($c); + $rhs = CompositeSpecification::create() + ->withSpecification($a) + ->withSpecification(CompositeSpecification::create()->withSpecification($b)->withSpecification($c)); + + Assert::same($this->ids($lhs), $this->ids($rhs)); + } + + /** @return array */ + public static function andIsAssociativeGenerators(): array + { + return ['a' => self::operand(), 'b' => self::operand(), 'c' => self::operand()]; + } + + #[Property(runs: 150)] + public function orIsCommutative(Specification $a, Specification $b): void + { + Assert::same( + $this->ids(OrSpecification::create($a, $b)), + $this->ids(OrSpecification::create($b, $a)), + ); + } + + /** @return array */ + public static function orIsCommutativeGenerators(): array + { + return ['a' => self::operand(), 'b' => self::operand()]; + } + + #[Property(runs: 100)] + public function orIsAssociative(Specification $a, Specification $b, Specification $c): void + { + $lhs = OrSpecification::create(OrSpecification::create($a, $b), $c); + $rhs = OrSpecification::create($a, OrSpecification::create($b, $c)); + + Assert::same($this->ids($lhs), $this->ids($rhs)); + } + + /** @return array */ + public static function orIsAssociativeGenerators(): array + { + return ['a' => self::operand(), 'b' => self::operand(), 'c' => self::operand()]; + } + + #[Property(runs: 150)] + public function andIsIdempotent(Specification $a): void + { + $duplicated = CompositeSpecification::create()->withSpecification($a)->withSpecification($a); + + Assert::same($this->ids($duplicated), $this->ids($a)); + } + + /** @return array */ + public static function andIsIdempotentGenerators(): array + { + return ['a' => self::operand()]; + } + + #[Property(runs: 150)] + public function deMorganAnd(Specification $a, Specification $b): void + { + $lhs = NotSpecification::create( + specification: CompositeSpecification::create()->withSpecification($a)->withSpecification($b), + ); + $rhs = OrSpecification::create(NotSpecification::create($a), NotSpecification::create($b)); + + Assert::same($this->ids($lhs), $this->ids($rhs)); + } + + /** @return array */ + public static function deMorganAndGenerators(): array + { + return ['a' => self::operand(), 'b' => self::operand()]; + } + + #[Property(runs: 150)] + public function deMorganOr(Specification $a, Specification $b): void + { + $lhs = NotSpecification::create(specification: OrSpecification::create($a, $b)); + $rhs = CompositeSpecification::create() + ->withSpecification(NotSpecification::create($a)) + ->withSpecification(NotSpecification::create($b)); + + Assert::same($this->ids($lhs), $this->ids($rhs)); + } + + /** @return array */ + public static function deMorganOrGenerators(): array + { + return ['a' => self::operand(), 'b' => self::operand()]; + } + + #[Property(runs: 100)] + public function distributive(Specification $a, Specification $b, Specification $c): void + { + $lhs = CompositeSpecification::create() + ->withSpecification($a) + ->withSpecification(OrSpecification::create($b, $c)); + $rhs = OrSpecification::create( + CompositeSpecification::create()->withSpecification($a)->withSpecification($b), + CompositeSpecification::create()->withSpecification($a)->withSpecification($c), + ); + + Assert::same($this->ids($lhs), $this->ids($rhs)); + } + + /** @return array */ + public static function distributiveGenerators(): array + { + return ['a' => self::operand(), 'b' => self::operand(), 'c' => self::operand()]; + } +}