aboutsummaryrefslogtreecommitdiffstats
path: root/tests/phpunit/unit/includes/libs/MemoizedCallableTest.php
blob: 1314bf8b735a2a93bedd3727942596b7e92deac3 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
<?php

use Wikimedia\TestingAccessWrapper;

/**
 * @covers MemoizedCallable
 */
class MemoizedCallableTest extends PHPUnit\Framework\TestCase {

	use MediaWikiCoversValidator;

	/**
	 * The memoized callable should relate inputs to outputs in the same
	 * way as the original underlying callable.
	 */
	public function testReturnValuePassedThrough() {
		$mock = $this->getMockBuilder( stdClass::class )
			->addMethods( [ 'reverse' ] )->getMock();
		$mock->method( 'reverse' )
			->willReturnCallback( 'strrev' );

		$memoized = new MemoizedCallable( [ $mock, 'reverse' ] );
		$this->assertEquals( 'flow', $memoized->invoke( 'wolf' ) );
	}

	/**
	 * Consecutive calls to the memoized callable with the same arguments
	 * should result in just one invocation of the underlying callable.
	 *
	 * @requires extension apcu
	 */
	public function testCallableMemoized() {
		$observer = $this->getMockBuilder( stdClass::class )
			->addMethods( [ 'computeSomething' ] )->getMock();
		$observer->expects( $this->once() )
			->method( 'computeSomething' )
			->willReturn( 'ok' );

		$memoized = new ArrayBackedMemoizedCallable( [ $observer, 'computeSomething' ] );

		// First invocation -- delegates to $observer->computeSomething()
		$this->assertEquals( 'ok', $memoized->invoke() );

		// Second invocation -- returns memoized result
		$this->assertEquals( 'ok', $memoized->invoke() );
	}

	public function testInvokeVariadic() {
		$memoized = new MemoizedCallable( 'sprintf' );
		$this->assertEquals(
			$memoized->invokeArgs( [ 'this is %s', 'correct' ] ),
			$memoized->invoke( 'this is %s', 'correct' )
		);
	}

	public function testShortcutMethod() {
		$this->assertEquals(
			'this is correct',
			MemoizedCallable::call( 'sprintf', [ 'this is %s', 'correct' ] )
		);
	}

	/**
	 * Outlier TTL values should be coerced to range 1 - 86400.
	 */
	public function testTTLMaxMin() {
		$memoized = TestingAccessWrapper::newFromObject( new MemoizedCallable( 'abs', 100000 ) );
		$this->assertEquals( 86400, $memoized->ttl );

		$memoized = TestingAccessWrapper::newFromObject( new MemoizedCallable( 'abs', -10 ) );
		$this->assertSame( 1, $memoized->ttl );
	}

	public static function makeA() {
		return 'a';
	}

	public static function makeB() {
		return 'b';
	}

	public static function makeRand() {
		return rand();
	}

	/**
	 * Closure names should be distinct.
	 */
	public function testMemoizedClosure() {
		$a = new MemoizedCallable( [ self::class, 'makeA' ] );
		$b = new MemoizedCallable( [ self::class, 'makeB' ] );

		$this->assertEquals( 'a', $a->invokeArgs() );
		$this->assertEquals( 'b', $b->invokeArgs() );

		$a = TestingAccessWrapper::newFromObject( $a );
		$b = TestingAccessWrapper::newFromObject( $b );

		$this->assertNotEquals(
			$a->callableName,
			$b->callableName
		);

		$c = new ArrayBackedMemoizedCallable( [ self::class, 'makeRand' ] );
		$this->assertEquals( $c->invokeArgs(), $c->invokeArgs(), 'memoized random' );
	}

	public function testNonScalarArguments() {
		$memoized = new MemoizedCallable( 'gettype' );
		$this->expectExceptionMessage( "non-scalar argument" );
		$this->expectException( InvalidArgumentException::class );
		$memoized->invoke( (object)[] );
	}

	public function testUnnamedCallable() {
		$this->expectExceptionMessage( 'Cannot memoize unnamed closure' );
		$this->expectException( InvalidArgumentException::class );
		$memoized = new MemoizedCallable( static function () {
			return 'a';
		} );
	}

	public function testNotCallable() {
		$this->expectExceptionMessage( "must be an instance of callable" );
		$this->expectException( InvalidArgumentException::class );
		$memoized = new MemoizedCallable( 14 );
	}
}

/**
 * A MemoizedCallable subclass that stores function return values
 * in an instance property rather than APC or APCu.
 */
class ArrayBackedMemoizedCallable extends MemoizedCallable {
	private $cache = [];

	protected function fetchResult( $key, &$success ) {
		if ( array_key_exists( $key, $this->cache ) ) {
			$success = true;
			return $this->cache[$key];
		}
		$success = false;
		return false;
	}

	protected function storeResult( $key, $result ) {
		$this->cache[$key] = $result;
	}
}