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