packages/deriver/tests/Unit/Deriver/Evaluator/Builtin/ArrayMutationTest.php
1<?php
2
3declare(strict_types=1);
4
5namespace Tests\Unit\Deriver\Evaluator\Builtin;
6
7use Deriver\Deriver\Evaluator\Builtin\ArrayMutation;
8use Deriver\Deriver\Evaluator\Term;
9use PHPUnit\Framework\Attributes\CoversClass;
10use PHPUnit\Framework\Attributes\DataProvider;
11use PHPUnit\Framework\Attributes\Small;
12use PHPUnit\Framework\Attributes\UsesClass;
13use PHPUnit\Framework\TestCase;
14
15#[CoversClass(ArrayMutation::class)]
16#[UsesClass(\Deriver\Deriver\Evaluator\Scalar\Arrays::class)]
17#[UsesClass(\Deriver\Deriver\Evaluator\Scalar\Operations::class)]
18#[UsesClass(Term::class)]
19#[Small]
20final class ArrayMutationTest extends TestCase
21{
22 /**
23 * @param list<Term> $values Bound arguments
24 * @param array<string, mixed> $expected Updated array and return value
25 */
26 #[DataProvider('providerClosedMutations')]
27 public function testApplyFollowsPhpKeyRenumberingOnClosedArrays(string $name, array $values, array $expected): void
28 {
29 self::assertSame($expected, (new ArrayMutation())->apply($name, $values)->native());
30 }
31
32 /**
33 * @return iterable<string, array{string, list<Term>, array<string, mixed>}>
34 */
35 public static function providerClosedMutations(): iterable
36 {
37 $mixed = Term::fromNative([5 => 'a', 'k' => 'b', 9 => 'c']);
38 yield 'shift renumbers integer keys' => ['array_shift', [$mixed], ['array' => ['k' => 'b', 0 => 'c'], 'result' => 'a']];
39 yield 'pop keeps keys' => ['array_pop', [$mixed], ['array' => [5 => 'a', 'k' => 'b'], 'result' => 'c']];
40 yield 'push appends after the largest key' => ['array_push', [$mixed, Term::fromNative(['x', 'y'])], ['array' => [5 => 'a', 'k' => 'b', 9 => 'c', 10 => 'x', 11 => 'y'], 'result' => 5, 'appended' => true, 'partial' => [5 => 'a', 'k' => 'b', 9 => 'c', 10 => 'x', 11 => 'y']]];
41 yield 'unshift renumbers integer keys' => ['array_unshift', [$mixed, Term::fromNative(['x'])], ['array' => ['x', 'a', 'k' => 'b', 'c'], 'result' => 4]];
42 yield 'unshift without values renumbers' => ['array_unshift', [$mixed, Term::array([])], ['array' => ['a', 'k' => 'b', 'c'], 'result' => 3]];
43 yield 'shift of an empty array' => ['array_shift', [Term::array([])], ['array' => [], 'result' => null]];
44 }
45
46 public function testRemoveTracksTheNextIndexLikeArrayPop(): void
47 {
48 $mutation = new ArrayMutation();
49 self::assertSame(2, $mutation->remove(Term::fromNative([1, 2, 3]), false)->operands['array']->attributes['next'] ?? null);
50 self::assertSame(4, $mutation->remove(Term::fromNative([3 => 'a', 1 => 'b']), false)->operands['array']->attributes['next'] ?? null);
51 self::assertSame(5, $mutation->remove(Term::fromNative([5 => 'a']), false)->operands['array']->attributes['next'] ?? null);
52 self::assertSame(PHP_INT_MAX, $mutation->remove(Term::fromNative([PHP_INT_MAX => 'a']), false)->operands['array']->attributes['next'] ?? null);
53 self::assertArrayNotHasKey('next', $mutation->remove(new Term(\Deriver\Deriver\Evaluator\TermKind::Array, operands: [7 => Term::constant(1)], attributes: ['open' => false, 'next' => 9]), true)->operands['array']->attributes);
54 }
55
56 public function testPushKeepsValuesInsertedBeforeTheOccupiedMaximumIndex(): void
57 {
58 $record = (new ArrayMutation())->push(Term::fromNative([PHP_INT_MAX - 1 => 'a']), [Term::constant('b'), Term::constant('c')]);
59 self::assertFalse($record->operands['appended']->literal);
60 self::assertSame([PHP_INT_MAX - 1 => 'a', PHP_INT_MAX => 'b'], $record->operands['partial']->native());
61 }
62
63 public function testRemoveKeepsSymbolicArraysAsCorrelatedCalls(): void
64 {
65 $array = Term::parameter('c', 'array');
66 $record = (new ArrayMutation())->remove($array, true);
67 self::assertSame(['intrinsic', 'array_shift', [$array], 'mixed'], [$record->operands['result']->kind->value, $record->operands['result']->literal, $record->operands['result']->operands, $record->operands['result']->attributes['type']]);
68 self::assertSame(['array_shift:array', 'array'], [$record->operands['array']->literal, $record->operands['array']->attributes['type']]);
69 self::assertSame('array_pop', (new ArrayMutation())->remove(Term::array([Term::constant(1)], true), false)->operands['result']->literal);
70 }
71
72 public function testPushOnSymbolicArraysKeepsAnOccupiedIndexFailure(): void
73 {
74 $array = Term::parameter('c', 'array');
75 $record = (new ArrayMutation())->push($array, [Term::constant('x'), Term::constant('y')]);
76 self::assertSame(\Deriver\Deriver\Evaluator\TermKind::ArrayMerge, $record->operands['array']->kind);
77 self::assertSame(['intrinsic', 'count'], [$record->operands['result']->kind->value, $record->operands['result']->literal]);
78 self::assertSame('array_push:appendable', $record->operands['appended']->literal);
79 self::assertSame('array_push:partial', $record->operands['partial']->literal);
80 self::assertSame($array, (new ArrayMutation())->push($array, [Term::constant('x')])->operands['partial']);
81 self::assertTrue((new ArrayMutation())->push($array, [])->operands['appended']->literal);
82 }
83
84 public function testPushOnRenumberedMergesCannotReachTheMaximumIndex(): void
85 {
86 $merged = new Term(\Deriver\Deriver\Evaluator\TermKind::ArrayMerge, operands: [Term::array([]), Term::parameter('c', 'array')], attributes: ['type' => 'array']);
87 self::assertTrue((new ArrayMutation())->push($merged, [Term::constant('x')])->operands['appended']->literal);
88 }
89
90 public function testPrependOnSymbolicArraysKeepsTheKnownHead(): void
91 {
92 $record = (new ArrayMutation())->prepend(Term::parameter('c', 'array'), [Term::constant('id')]);
93 self::assertSame(\Deriver\Deriver\Evaluator\TermKind::ArrayMerge, $record->operands['array']->kind);
94 self::assertSame(['id'], $record->operands['array']->operands[0]->native());
95 }
96
97 public function testApplyRejectsNamedAndUnknownVariadicValues(): void
98 {
99 $mutation = new ArrayMutation();
100 self::assertSame('ArgumentCountError', $mutation->apply('array_push', [Term::array([]), Term::fromNative(['x' => 1])])->literal);
101 self::assertSame('ArgumentCountError', $mutation->apply('array_unshift', [Term::array([]), Term::fromNative(['x' => 1])])->literal);
102 self::assertSame('UNSUPPORTED_EVALUATOR_CASE', $mutation->apply('array_push', [Term::array([]), Term::parameter('values', 'array')])->literal);
103 }
104
105 public function testClosedAcceptsOnlyArraysWithoutUnknownEntries(): void
106 {
107 $mutation = new ArrayMutation();
108 self::assertTrue($mutation->closed(Term::array([])));
109 self::assertFalse($mutation->closed(Term::array([], true)));
110 self::assertFalse($mutation->closed(Term::parameter('c', 'array')));
111 }
112
113 public function testCountMatchesTheBoundCountCall(): void
114 {
115 $array = Term::parameter('c', 'array');
116 $count = (new ArrayMutation())->count($array);
117 self::assertSame([$array, 0], [$count->operands[0], $count->operands[1]->literal]);
118 self::assertSame('int', $count->attributes['type']);
119 }
120
121 public function testRecordAddsAppendOutcomesOnlyWhenBothAreKnown(): void
122 {
123 $mutation = new ArrayMutation();
124 self::assertSame(['array', 'result'], array_keys($mutation->record(Term::array([]), Term::constant(0))->operands));
125 self::assertSame(['array', 'result', 'appended', 'partial'], array_keys($mutation->record(Term::array([]), Term::constant(0), Term::constant(true), Term::array([]))->operands));
126 }
127}
128