Skip to content

Commit e9bfbe1

Browse files
committed
Issue #110: Add ExportedArrayDiffer + tests and interface.
1 parent e4698c9 commit e9bfbe1

File tree

9 files changed

+509
-0
lines changed

9 files changed

+509
-0
lines changed

src/Diff/DifferInterface.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Ock\Testing\Diff;
6+
7+
interface DifferInterface {
8+
9+
/**
10+
* Creates a diff between two arrays.
11+
*
12+
* @param array $before
13+
* @param array $after
14+
*
15+
* @return array
16+
*/
17+
public function compare(array $before, array $after): array;
18+
19+
}

src/Diff/ExportedArrayDiffer.php

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Ock\Testing\Diff;
6+
7+
use Symfony\Component\Yaml\Tag\TaggedValue;
8+
9+
class ExportedArrayDiffer implements DifferInterface {
10+
11+
protected array $nonListProperties = [];
12+
13+
protected array $identifyingProperties = [];
14+
15+
public function withNonListProperty(string $class, string $property): static {
16+
$clone = clone $this;
17+
$clone->nonListProperties[$class][$property] = TRUE;
18+
return $clone;
19+
}
20+
21+
public function withIdentifyingProperty(string $class, string $property): static {
22+
$clone = clone $this;
23+
$clone->identifyingProperties[$class][$property] = TRUE;
24+
return $clone;
25+
}
26+
27+
public function compare(array $before, array $after): array {
28+
if ($before === $after) {
29+
return [];
30+
}
31+
$diff = $this->compareArrays($before, $after);
32+
if ($diff === false) {
33+
return [
34+
'-' => $before,
35+
'+' => $after,
36+
];
37+
}
38+
return $diff;
39+
}
40+
41+
protected function compareValues(mixed $before, mixed $after, bool $could_be_list = true): array|false {
42+
if ($before === $after) {
43+
return [];
44+
}
45+
if (!is_array($before) || !is_array($after)) {
46+
return false;
47+
}
48+
return $this->compareArrays($before, $after, $could_be_list);
49+
}
50+
51+
protected function compareArrays(array $before, array $after, bool $could_be_list = true): array|false {
52+
if ($could_be_list && array_is_list($before) && array_is_list($after)) {
53+
return $this->compareLists($before, $after);
54+
}
55+
if (array_key_first($before) === 'class' && array_key_first($after) === 'class'
56+
&& is_string($before['class']) && is_string($after['class'])
57+
&& preg_match(
58+
'/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)*$/',
59+
$before['class'],
60+
)
61+
&& preg_match(
62+
'/^[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(\\\\[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*)*$/',
63+
$after['class'],
64+
)
65+
) {
66+
if ($before['class'] !== $after['class']) {
67+
// Don't go deeper if the two objects have a different class.
68+
return false;
69+
}
70+
return $this->compareExportedObjects($before, $after, $before['class']);
71+
}
72+
return $this->compareAssoc($before, $after);
73+
}
74+
75+
/**
76+
* Compares two lists.
77+
*
78+
* @param list<mixed> $before
79+
* @param list<mixed> $after
80+
*
81+
* @return array|false
82+
*/
83+
protected function compareLists(array $before, array $after): array|false {
84+
$diff = $this->doCompareLists($before, $after);
85+
if (!$diff || count($diff) === count($before) + count($after)) {
86+
// The two lists are completely different.
87+
return false;
88+
}
89+
return $diff;
90+
}
91+
92+
/**
93+
* Compares two lists recursively.
94+
*
95+
* @param list<mixed> $before
96+
* @param list<mixed> $after
97+
* @param int $i_before
98+
* @param int $i_after
99+
*
100+
* @return array
101+
*/
102+
protected function doCompareLists(array $before, array $after, int $i_before = 0, int $i_after = 0): array {
103+
$diff = [];
104+
while (true) {
105+
if ($i_before >= count($before)) {
106+
// There are more items in "after" list.
107+
for (; $i_after < count($after); ++$i_after) {
108+
$diff[] = new TaggedValue('add', $after[$i_after]);
109+
}
110+
return $diff;
111+
}
112+
if ($i_after >= count($after)) {
113+
// There are more items in "before" list.
114+
for (; $i_before < count($before); ++$i_before) {
115+
$diff[] = new TaggedValue('--', $before[$i_before]);
116+
}
117+
return $diff;
118+
}
119+
$item_diff = $this->compareValues($before[$i_before], $after[$i_after]);
120+
if ($item_diff === []) {
121+
// The two values are the same.
122+
++$i_before;
123+
++$i_after;
124+
continue;
125+
}
126+
// The two items are completely different.
127+
$diff_minus = $this->doCompareLists($before, $after, $i_before + 1, $i_after);
128+
$diff_plus = $this->doCompareLists($before, $after, $i_before, $i_after + 1);
129+
if ($item_diff !== false) {
130+
$diff_eq = $this->doCompareLists($before, $after, $i_before + 1, $i_after + 1);
131+
if (count($diff_eq) < count($diff_minus) && count($diff_eq) < count($diff_plus)) {
132+
return [
133+
...$diff,
134+
new TaggedValue('diff', $item_diff),
135+
...$diff_eq,
136+
];
137+
}
138+
}
139+
if (count($diff_minus) <= count($diff_plus)) {
140+
return [
141+
...$diff,
142+
new TaggedValue('--', $before[$i_before]),
143+
...$diff_minus,
144+
];
145+
}
146+
else {
147+
return [
148+
...$diff,
149+
new TaggedValue('add', $after[$i_after]),
150+
...$diff_plus,
151+
];
152+
}
153+
}
154+
}
155+
156+
protected function compareAssoc(array $before, array $after): array|false {
157+
// @todo Also compare order of keys?
158+
ksort($before);
159+
ksort($after);
160+
$shared_keys = array_intersect(
161+
array_keys($before),
162+
array_keys($after),
163+
);
164+
if (!$shared_keys) {
165+
return false;
166+
}
167+
$diff = [];
168+
$similar = false;
169+
foreach (array_diff_key($before, $after) as $key => $item) {
170+
$diff['-- ' . $key] = $item;
171+
}
172+
foreach ($shared_keys as $key) {
173+
$item_diff = $this->compareValues($before[$key], $after[$key]);
174+
if ($item_diff === false) {
175+
$diff['~- ' . $key] = $before[$key];
176+
$diff['~+ ' . $key] = $after[$key];
177+
}
178+
elseif ($item_diff) {
179+
$diff['~~ ' . $key] = $item_diff;
180+
$similar = true;
181+
}
182+
else {
183+
$similar = true;
184+
}
185+
}
186+
if (!$similar) {
187+
return false;
188+
}
189+
foreach (array_diff_key($after, $before) as $key => $item) {
190+
$diff['++ ' . $key] = $item;
191+
}
192+
return $diff;
193+
}
194+
195+
protected function compareExportedObjects(array $before, array $after, string $class): array|false {
196+
unset($before['class'], $after['class']);
197+
$info = ['class' => $class];
198+
if (isset($this->identifyingProperties[$class])) {
199+
$info_before = array_intersect_key($before, $this->identifyingProperties[$class]);
200+
$info_after = array_intersect_key($after, $this->identifyingProperties[$class]);
201+
if ($info_before !== $info_after) {
202+
return $this->compareAssoc($before, $after);
203+
}
204+
$info += $info_before;
205+
}
206+
$shared_keys = array_intersect(
207+
array_keys($before),
208+
array_keys($after),
209+
);
210+
$diff = [];
211+
foreach (array_diff_key($before, $after) as $key => $item) {
212+
$diff['-- ' . $key] = $item;
213+
}
214+
foreach ($shared_keys as $key) {
215+
$item_diff = $this->compareExportedObjectProperty($class, $key, $before[$key], $after[$key]);
216+
if ($item_diff === false) {
217+
$diff['~- ' . $key] = $before[$key];
218+
$diff['~+ ' . $key] = $after[$key];
219+
}
220+
elseif ($item_diff) {
221+
$diff['~~ ' . $key] = $item_diff;
222+
}
223+
}
224+
if (!$diff) {
225+
return [];
226+
}
227+
return $info + $diff;
228+
}
229+
230+
protected function compareExportedObjectProperty(string $class, string $property, mixed $before, mixed $after): array|null|false|TaggedValue {
231+
if (is_array($before) && is_array($after)
232+
&& array_is_list($before) && array_is_list($after)
233+
&& isset($this->nonListProperties[$class][$property])
234+
) {
235+
// Suppress list.
236+
return $this->compareValues($before, $after, false);
237+
}
238+
return $this->compareValues($before, $after);
239+
}
240+
241+
}

src/FileAsRecordedTrait.php

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Ock\Testing;
6+
7+
trait FileAsRecordedTrait {
8+
9+
/**
10+
* Asserts that a file is as recorded.
11+
*
12+
* @param string $file
13+
* File path.
14+
* @param string|null $content
15+
* New file content.
16+
*/
17+
protected function assertFileAsRecorded(string $file, string|null $content): void {
18+
if ($this->isRecording()) {
19+
if ($content === null) {
20+
if (file_exists($file)) {
21+
unlink($file);
22+
}
23+
}
24+
else {
25+
if (!is_dir(dirname($file))) {
26+
mkdir(dirname($file), recursive: true);
27+
}
28+
file_put_contents($file, $content);
29+
}
30+
$this->addToAssertionCount(1);
31+
}
32+
else {
33+
if (!file_exists($file)) {
34+
$this->assertNull($content, "File '$file' is missing.");
35+
}
36+
else {
37+
$expected = file_get_contents($file);
38+
$this->assertSame($expected, $content, "Content in '$file'.");
39+
}
40+
}
41+
}
42+
43+
/**
44+
* Determines if the test is in recording mode.
45+
*
46+
* @return bool
47+
* TRUE if in recording mode, FALSE if in replay mode.
48+
*/
49+
abstract protected function isRecording(): bool;
50+
51+
}

src/FixturesPathTrait.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Ock\Testing;
6+
7+
use Ock\ClassDiscovery\NamespaceDirectory;
8+
9+
trait FixturesPathTrait {
10+
11+
/**
12+
* Gets the base fixtures path for all methods of this class.
13+
*
14+
* @return string
15+
* Directory without ending '/'.
16+
*/
17+
protected static function getClassFixturesPath(): string {
18+
$reflection_class = new \ReflectionClass(static::class);
19+
$class_dir = NamespaceDirectory::fromReflectionClass($reflection_class);
20+
return $class_dir->getPackageDirectory(level: 3)
21+
. '/fixtures'
22+
. $class_dir->getRelativePath('', 3)
23+
. '/' . $reflection_class->getShortName();
24+
}
25+
26+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
before:
2+
- before
3+
-
4+
a: 'A orig'
5+
b: 'B orig'
6+
-
7+
a: 'A orig'
8+
b: 'B orig'
9+
- after
10+
after:
11+
- before
12+
-
13+
a: 'A changed'
14+
b: 'B changed'
15+
-
16+
a: 'A changed'
17+
b: 'B orig'
18+
- after
19+
diff:
20+
- !--
21+
a: 'A orig'
22+
b: 'B orig'
23+
- !add
24+
a: 'A changed'
25+
b: 'B changed'
26+
- !diff
27+
'~- a': 'A orig'
28+
'~+ a': 'A changed'
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
before:
2+
- hello
3+
- world
4+
after:
5+
- goodbye
6+
- world
7+
diff:
8+
- !-- hello
9+
- !add goodbye

0 commit comments

Comments
 (0)