diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 1d2c578..4baa377 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -11,12 +11,12 @@ jobs: # Run tests on all OS's and HHVM versions, even if one fails fail-fast: false matrix: - os: [ ubuntu ] + os: [ ubuntu-20.04 ] hhvm: - '4.128' - - latest - - nightly - runs-on: ${{matrix.os}}-latest + - '4.153' + - '4.168' + runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v2 - name: Create branch for version alias diff --git a/src/Assert.hack b/src/Assert.hack index 70712ca..88512e7 100644 --- a/src/Assert.hack +++ b/src/Assert.hack @@ -451,6 +451,23 @@ abstract class Assert { ); } + public function assertContainsStrict( + mixed $needle, + Container $haystack, + string $message, + )[]: void { + if (!C\contains($haystack, $needle)) { + throw new ExpectationFailedException( + Str\format( + "%s\nFailed asserting that %s contains %s", + $message, + \var_export_pure($haystack), + \var_export_pure($needle), + ), + ); + } + } + public function assertNotContains( mixed $needle, mixed $haystack, @@ -587,7 +604,46 @@ abstract class Assert { Traversable $actual, string $msg = '', ): void { - $this->assertEquals(Vec\sort($expected), Vec\sort($actual), $msg); + $expected = vec($expected); + $actual = vec($actual); + + list($e_null_count, $e_strings, $e_ints, $e_floats, $e_bools, $e_rest) = + self::segregateByType($expected); + + list($a_null_count, $a_strings, $a_ints, $a_floats, $a_bools, $a_rest) = + self::segregateByType($actual); + + $this->assertEquals( + $e_null_count, + $a_null_count, + 'Amount of nulls comparison: '.$msg, + ); + $this->assertEquals( + Vec\sort($e_strings), + Vec\sort($a_strings), + 'Checking strings only: '.$msg, + ); + $this->assertEquals( + Vec\sort($e_ints), + Vec\sort($a_ints), + 'Checking ints only: '.$msg, + ); + $this->assertEquals( + Vec\sort($e_floats), + Vec\sort($a_floats), + 'Checking floats only: '.$msg, + ); + $this->assertEquals( + Vec\sort($e_bools), + Vec\sort($a_bools), + 'Checking bools only: '.$msg, + ); + + $this->assertContentsEqualSlowPath( + $e_rest, + $a_rest, + 'Checking non-scalars: '.$msg, + ); } /** * Checks that a collection is sorted according to some criterion. @@ -640,9 +696,8 @@ abstract class Assert { \var_export($pair[1], true), ); - throw new ExpectationFailedException( - $main_message.': '.$failure_detail, - ); + throw + new ExpectationFailedException($main_message.': '.$failure_detail); } $index++; @@ -690,4 +745,61 @@ abstract class Assert { return $out; } + /** + * Each returned vec, except for the last one is naively sortable. + * The number of nulls is returned, since there is only one value of this type. + * Returning a `vec`, just to sort and count them would be rather silly... + */ + private static function segregateByType(vec $values)[]: ( + int /*number of nulls*/, + vec, + vec, + vec, + vec, + vec, + ) { + $number_of_nulls = 0; + $strings = vec[]; + $ints = vec[]; + $floats = vec[]; + $bools = vec[]; + $rest = vec[]; + + foreach ($values as $v) { + if ($v is null) { + ++$number_of_nulls; + } else if ($v is string) { + $strings[] = $v; + } else if ($v is int) { + $ints[] = $v; + } else if ($v is float) { + $floats[] = $v; + } else if ($v is bool) { + $bools[] = $v; + } else { + $rest[] = $v; + } + } + + return tuple($number_of_nulls, $strings, $ints, $floats, $bools, $rest); + } + + private function assertContentsEqualSlowPath( + vec $expected, + vec $actual, + string $msg, + ): void { + $this->assertEquals(C\count($expected), C\count($actual), $msg); + + // O(n^2), be prepared to spin here for a while... + foreach ($expected as $e) { + $this->assertContainsStrict($e, $actual, $msg); + } + + // This test needs to be reflected. + // vec[A, A, A] compare to vec[A, B, C] passes the loop above. + foreach ($actual as $a) { + $this->assertContainsStrict($a, $expected, $msg); + } + } } diff --git a/src/ExpectObj.hack b/src/ExpectObj.hack index 10df8cd..6d4084e 100644 --- a/src/ExpectObj.hack +++ b/src/ExpectObj.hack @@ -16,7 +16,7 @@ use type Facebook\HackTest\ExpectationFailedException; /* HHAST_IGNORE_ERROR[FinalOrAbstractClass] Intentional non-final for backward compatibility */ class ExpectObj extends Assert { - public function __construct(private T $var) {} + public function __construct(private T $var)[] {} /************************************** ************************************** @@ -55,6 +55,22 @@ class ExpectObj extends Assert { $this->assertEquals($expected, $this->var, $msg); } + public function toBeOfType<<<__Enforceable>> reify Treified>( + string $msg = '', + mixed ...$args + )[]: Treified { + $v = $this->var; + if (!$v is Treified) { + throw new ExpectationFailedException(Str\format( + "%s\nFailed to assert that %s was of the expected type.", + \vsprintf($msg, $args), + \var_export_pure($v), + )); + } + + return $v; + } + /** * Float comparison can give false positives - this will only error if $actual * and $expected are not within $delta of each other. diff --git a/src/expect.hack b/src/expect.hack index 232cdc8..6bbbe03 100644 --- a/src/expect.hack +++ b/src/expect.hack @@ -23,6 +23,6 @@ namespace Facebook\FBExpect; * - Function Call Assertions * - Function Exception Assertions */ -function expect(T $obj): ExpectObj { +function expect(T $obj)[]: ExpectObj { return new ExpectObj($obj); } diff --git a/tests/ExpectObjTest.hack b/tests/ExpectObjTest.hack index 9fbc87f..2f3ad6a 100644 --- a/tests/ExpectObjTest.hack +++ b/tests/ExpectObjTest.hack @@ -108,6 +108,21 @@ final class ExpectObjTest extends HackTest { }); expect(2)->toEqualWithDelta(1.99, 0.01); + + // Optimized cases + expect(vec[2, 1])->toHaveSameContentAs(vec[1, 2]); + expect(vec['2', '1'])->toHaveSameContentAs(vec['1', '2']); + expect(vec[2., 1.])->toHaveSameContentAs(vec[1., 2.]); + expect(vec[true, false])->toHaveSameContentAs(vec[false, true]); + expect(vec[null, null])->toHaveSameContentAs(vec[null, null]); + expect(vec[null, true, 1., '1', 1])->toHaveSameContentAs( + vec[1, '1', 1., true, null], + ); + + // Slow path cases + expect(vec[dict['nested' => $o], $o, $o2])->toHaveSameContentAs( + vec[$o, $o2, dict['nested' => $o]], + ); } /** @@ -181,6 +196,13 @@ final class ExpectObjTest extends HackTest { dict['k1' => 'v1', 'k2' => 'v2'], dict['k1' => 'v2'], ], + + vec['toHaveSameContentAs', vec[true], vec[1]], + vec['toHaveSameContentAs', vec['1'], vec[1]], + vec['toHaveSameContentAs', vec[1.], vec[1]], + vec['toHaveSameContentAs', vec[null], vec[0]], + vec['toHaveSameContentAs', vec[$o, $o], vec[$o, $o, $o]], + vec['toHaveSameContentAs', vec[$o, $o, $this], vec[$o, $o, $o]], ]; } @@ -549,6 +571,18 @@ final class ExpectObjTest extends HackTest { expect(fun('time'))->notToThrow(); } + public function testAssertToBeOfType(): void { + expect(1)->toBeOfType() |> self::takesT($$); + expect(new \stdClass())->toBeOfType<\stdClass>() + |> self::takesT<\stdClass>($$); + expect(1)->toBeOfType() |> self::takesT($$); + + expect(() ==> { + // inferred type is the generic type of toBeOfType(), not the passed type. + expect(1)->toBeOfType() |> self::takesT($$); + })->toThrow(ExpectationFailedException::class, 'the expected type'); + } + public static function exampleStaticCallable(): void { throw new \Exception('Static method called!'); } @@ -556,4 +590,6 @@ final class ExpectObjTest extends HackTest { public function exampleInstanceCallable(): void { throw new \Exception('Instance method called!'); } + + private static function takesT(T $_)[]: void {} }