diff --git a/lang/en/table.php b/lang/en/table.php index 53a7f0d1009..ed26c91bae8 100644 --- a/lang/en/table.php +++ b/lang/en/table.php @@ -23,4 +23,5 @@ */ $string['downloadas'] = 'Download table data as'; +$string['missingrequiredfields'] = 'One or more required filters were missing ({$a})'; $string['privacy:metadata'] = 'The Table API does not currently store any user data'; diff --git a/lib/table/classes/local/filter/filter.php b/lib/table/classes/local/filter/filter.php new file mode 100644 index 00000000000..f98d3f6aa68 --- /dev/null +++ b/lib/table/classes/local/filter/filter.php @@ -0,0 +1,254 @@ +. + +/** + * Table filterset. + * + * @package core + * @category table + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use Countable; +use InvalidArgumentException; +use Iterator; + +/** + * Class representing a generic filter of any type. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filter implements Countable, Iterator { + + /** @var in The default filter type (ANY) */ + const JOINTYPE_DEFAULT = 1; + + /** @var int None of the following match */ + const JOINTYPE_NONE = 0; + + /** @var int Any of the following match */ + const JOINTYPE_ANY = 1; + + /** @var int All of the following match */ + const JOINTYPE_ALL = 2; + + /** @var string The name of this filter */ + protected $name = null; + + /** @var int The join type currently in use */ + protected $jointype = self::JOINTYPE_DEFAULT; + + /** @var array The list of active filter values */ + protected $filtervalues = []; + + /** @var int[] valid join types */ + protected $jointypes = [ + self::JOINTYPE_NONE, + self::JOINTYPE_ANY, + self::JOINTYPE_ALL, + ]; + + /** @var int The current iterator position */ + protected $iteratorposition = null; + + /** + * Constructor for the generic filter class. + * + * @param string $name The name of the current filter. + * @param int $jointype The join to use when combining the filters. + * See the JOINTYPE_ constants for further information on the field. + * @param mixed[] $values An array of filter objects to be applied. + */ + public function __construct(string $name, ?int $jointype = null, ?array $values = null) { + $this->name = $name; + + if ($jointype !== null) { + $this->set_join_type($jointype); + } + + if (!empty($values)) { + foreach ($values as $value) { + $this->add_filter_value($value); + } + } + } + + /** + * Reset the iterator position. + */ + public function reset_iterator(): void { + $this->iteratorposition = null; + } + + /** + * Return the current filter value. + */ + public function current() { + if ($this->iteratorposition === null) { + $this->rewind(); + } + + if ($this->iteratorposition === null) { + return null; + } + + return $this->filtervalues[$this->iteratorposition]; + } + + /** + * Returns the current position of the iterator. + * + * @return int + */ + public function key() { + if ($this->iteratorposition === null) { + $this->rewind(); + } + + return $this->iteratorposition; + } + + /** + * Rewind the Iterator position to the start. + */ + public function rewind(): void { + if ($this->iteratorposition === null) { + $this->sort_filter_values(); + } + + if (count($this->filtervalues)) { + $this->iteratorposition = 0; + } + } + + /** + * Move to the next value in the list. + */ + public function next(): void { + ++$this->iteratorposition; + } + + /** + * Check if the current position is valid. + * + * @return bool + */ + public function valid(): bool { + return isset($this->filtervalues[$this->iteratorposition]); + } + + /** + * Return the number of contexts. + * + * @return int + */ + public function count(): int { + return count($this->filtervalues); + } + + /** + * Return the name of the filter. + * + * @return string + */ + public function get_name(): string { + return $this->name; + } + + /** + * Specify the type of join to employ for the filter. + * + * @param int $jointype The join type to use using one of the supplied constants + * @return self + */ + public function set_join_type(int $jointype): self { + if (array_search($jointype, $this->jointypes) === false) { + throw new InvalidArgumentException('Invalid join type specified'); + } + + $this->jointype = $jointype; + + return $this; + } + + /** + * Return the currently specified join type. + * + * @return int + */ + public function get_join_type(): int { + return $this->jointype; + } + + /** + * Add a value to the filter. + * + * @param mixed $value + * @return self + */ + public function add_filter_value($value): self { + if ($value === null) { + // Null values are usually invalid. + return $this; + } + + if ($value === '') { + // Empty strings are invalid. + return $this; + } + + if (array_search($value, $this->filtervalues) !== false) { + // Remove duplicates. + return $this; + } + + $this->filtervalues[] = $value; + + // Reset the iterator position. + $this->reset_iterator(); + + return $this; + } + + /** + * Sort the filter values to ensure reliable, and consistent output. + */ + protected function sort_filter_values(): void { + // Sort the filter values to ensure consistent output. + // Note: This is not a locale-aware sort, but we don't need this. + // It's primarily for consistency, not for actual sorting. + sort($this->filtervalues); + + $this->reset_iterator(); + } + + /** + * Return the current filter values. + * + * @return mixed[] + */ + public function get_filter_values(): array { + $this->sort_filter_values(); + return $this->filtervalues; + } +} diff --git a/lib/table/classes/local/filter/filterset.php b/lib/table/classes/local/filter/filterset.php new file mode 100644 index 00000000000..e49fb5aa39c --- /dev/null +++ b/lib/table/classes/local/filter/filterset.php @@ -0,0 +1,288 @@ +. + +/** + * Table filterset. + * + * @package core + * @category table + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use InvalidArgumentException; +use UnexpectedValueException; +use moodle_exception; + +/** + * Class representing a set of filters. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class filterset { + /** @var in The default filter type (ANY) */ + const JOINTYPE_DEFAULT = 1; + + /** @var int None of the following match */ + const JOINTYPE_NONE = 0; + + /** @var int Any of the following match */ + const JOINTYPE_ANY = 1; + + /** @var int All of the following match */ + const JOINTYPE_ALL = 2; + + /** @var int The join type currently in use */ + protected $jointype = self::JOINTYPE_DEFAULT; + + /** @var array The list of combined filter types */ + protected $filtertypes = null; + + /** @var array The list of active filters */ + protected $filters = []; + + /** @var int[] valid join types */ + protected $jointypes = [ + self::JOINTYPE_NONE, + self::JOINTYPE_ANY, + self::JOINTYPE_ALL, + ]; + + /** + * Specify the type of join to employ for the filter. + * + * @param int $jointype The join type to use using one of the supplied constants + * @return self + */ + public function set_join_type(int $jointype): self { + if (array_search($jointype, $this->jointypes) === false) { + throw new InvalidArgumentException('Invalid join type specified'); + } + + $this->jointype = $jointype; + + return $this; + } + + /** + * Return the currently specified join type. + * + * @return int + */ + public function get_join_type(): int { + return $this->jointype; + } + + /** + * Add the specified filter. + * + * @param filter $filter + * @return self + */ + public function add_filter(filter $filter): self { + $filtername = $filter->get_name(); + + if (array_key_exists($filtername, $this->filters)) { + // This filter already exists. + if ($this->filters[$filtername] === $filter) { + // This is the same value as already added. + // Just ignore it. + return $this; + } + + // This is a different value to last time. Fail as this is not supported. + throw new UnexpectedValueException( + "A filter of type '{$filtername}' has already been added. Check that you have the correct filter." + ); + } + + // Ensure that the filter is both known, and is of the correct type. + $validtypes = $this->get_all_filtertypes(); + + if (!array_key_exists($filtername, $validtypes)) { + // Unknown filter. + throw new InvalidArgumentException( + "The filter '{$filtername}' was not recognised." + ); + } + + // Check that the filter is of the correct type. + if (!is_a($filter, $validtypes[$filtername])) { + $actualtype = get_class($filter); + $requiredtype = $validtypes[$filtername]; + + throw new InvalidArgumentException( + "The filter '{$filtername}' was incorrectly specified as a {$actualtype}. It must be a {$requiredtype}." + ); + } + + // All good. Add the filter. + $this->filters[$filtername] = $filter; + + return $this; + } + + /** + * Add the specified filter from the supplied params. + * + * @param string $filtername The name of the filter to create + * @param mixed[] ...$args Additional arguments used to create this filter type + * @return self + */ + public function add_filter_from_params(string $filtername, ...$args): self { + // Fetch the list of valid filters by name. + $validtypes = $this->get_all_filtertypes(); + + if (!array_key_exists($filtername, $validtypes)) { + // Unknown filter. + throw new InvalidArgumentException( + "The filter '{$filtername}' was not recognised." + ); + } + + $filterclass = $validtypes[$filtername]; + + if (!class_exists($filterclass)) { + // Filter class cannot be class autoloaded. + throw new InvalidArgumentException( + "The filter class '{$filterclass}' for filter '{$filtername}' could not be found." + ); + } + + // Pass all supplied arguments to the constructor when adding a new filter. + // This allows for a wider definition of the the filter in child classes. + $this->add_filter(new $filterclass($filtername, ...$args)); + + return $this; + } + + /** + * Return the current set of filters. + * + * @return filter[] + */ + public function get_filters(): array { + // Sort the filters by their name to ensure consistent output. + // Note: This is not a locale-aware sort, but we don't need this. + // It's primarily for consistency, not for actual sorting. + asort($this->filters); + + return $this->filters; + } + + /** + * Check whether the filter has been added or not. + * + * @param string $filtername + * @return bool + */ + public function has_filter(string $filtername): bool { + // We do not check if the filtername is valid, only that it exists. + // This is an existence check and there is no benefit to doing any more. + return array_key_exists($filtername, $this->filters); + } + + /** + * Get the named filter. + * + * @param string $filtername + * @return filter + */ + public function get_filter(string $filtername): filter { + if (!array_key_exists($filtername, $this->get_all_filtertypes())) { + throw new UnexpectedValueException("The filter specified ({$filtername}) is invalid."); + } + + if (!array_key_exists($filtername, $this->filters)) { + throw new UnexpectedValueException("The filter specified ({$filtername}) has not been created."); + } + + return $this->filters[$filtername]; + } + + /** + * Confirm whether the filter has been correctly specified. + * + * @throws moodle_exception + */ + public function check_validity(): void { + // Ensure that all required filters are present. + $missing = []; + foreach (array_keys($this->get_required_filters()) as $filtername) { + if (!array_key_exists($filtername, $this->filters)) { + $missing[] = $filtername; + } + } + + if (!empty($missing)) { + throw new moodle_exception( + 'missingrequiredfields', + 'core_table', + '', + implode(get_string('listsep', 'langconfig') . ' ', $missing) + ); + } + } + + /** + * Get the list of required filters in an array of filtername => filter class type. + * + * @return array + */ + protected function get_required_filters(): array { + return []; + } + + /** + * Get the list of optional filters in an array of filtername => filter class type. + * + * @return array + */ + protected function get_optional_filters(): array { + return []; + } + + /** + * Get all filter valid types in an array of filtername => filter class type. + * + * @return array + */ + public function get_all_filtertypes(): array { + if ($this->filtertypes === null) { + $required = $this->get_required_filters(); + $optional = $this->get_optional_filters(); + + $conflicts = array_keys(array_intersect_key($required, $optional)); + + if (!empty($conflicts)) { + throw new InvalidArgumentException( + "Some filter types are both required, and optional: " . implode(', ', $conflicts) + ); + } + + $this->filtertypes = array_merge($required, $optional); + asort($this->filtertypes); + } + + return $this->filtertypes; + } +} diff --git a/lib/table/classes/local/filter/integer_filter.php b/lib/table/classes/local/filter/integer_filter.php new file mode 100644 index 00000000000..bf6e6539ac7 --- /dev/null +++ b/lib/table/classes/local/filter/integer_filter.php @@ -0,0 +1,65 @@ +. + +/** + * Integer filter. + * + * @package core + * @category table + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use TypeError; + +/** + * Class representing an integer filter. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class integer_filter extends filter { + /** + * Add a value to the filter. + * + * @param int $values + * @return self + */ + public function add_filter_value($value): parent { + if (!is_int($value)) { + $type = gettype($value); + if ($type === 'object') { + $type = get_class($value); + } + + throw new TypeError("The value supplied was of type '{$type}'. An integer was expected."); + } + + if (array_search($value, $this->filtervalues) !== false) { + // Remove duplicates. + return $this; + } + + $this->filtervalues[] = $value; + + return $this; + } +} diff --git a/lib/table/classes/local/filter/numeric_comparison_filter.php b/lib/table/classes/local/filter/numeric_comparison_filter.php new file mode 100644 index 00000000000..bc9dbd2f12e --- /dev/null +++ b/lib/table/classes/local/filter/numeric_comparison_filter.php @@ -0,0 +1,133 @@ +. + +/** + * Integer comparison filter to allow a comparison such as "> 42". + * + * @package core + * @category table + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use InvalidArgumentException; +use TypeError; + +/** + * Class representing an integer filter. + * + * @package core + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class numeric_comparison_filter extends filter { + /** + * Get the authoritative direction. + * + * @param string $direction The supplied direction + * @return string The authoritative direction + */ + protected function get_direction(string $direction): string { + $validdirections = [ + '=' => '==', + '==' => '==', + '===' => '===', + + '>' => '>', + '=>' => '=>', + '<' => '<', + '<=' => '<=', + ]; + + if (!array_key_exists($direction, $validdirections)) { + throw new InvalidArgumentException("Invalid direction specified '{$direction}'."); + } + + return $validdirections[$direction]; + } + + /** + * Add a value to the filter. + * + * @param string $value A json-encoded array containing a direction, and comparison value + * @return self + */ + public function add_filter_value($value): parent { + if (!is_string($value)) { + $type = gettype($value); + if ($type === 'object') { + $type = get_class($value); + } + + throw new TypeError( + "The value supplied was of type '{$type}'. A string representing a json-encoded value was expected." + ); + } + + $data = json_decode($value); + + if ($data === null) { + throw new InvalidArgumentException( + "A json-encoded object containing both a direction, and comparison value was expected." + ); + } + + if (!is_object($data)) { + $type = gettype($value); + throw new InvalidArgumentException( + "The value supplied was a json encoded '{$type}'. " . + "An object containing both a direction, and comparison value was expected." + ); + } + + if (!property_exists($data, 'direction')) { + throw new InvalidArgumentException("A 'direction' must be provided."); + } + $direction = $this->get_direction($data->direction); + + if (!property_exists($data, 'value')) { + throw new InvalidArgumentException("A 'value' must be provided."); + } + $value = $data->value; + + if (!is_numeric($value)) { + $type = gettype($value); + if ($type === 'object') { + $type = get_class($value); + } + + throw new TypeError("The value supplied was of type '{$type}'. A numeric value was expected."); + } + + $fullvalue = (object) [ + 'direction' => $direction, + 'value' => $value, + ]; + + if (array_search($fullvalue, $this->filtervalues) !== false) { + // Remove duplicates. + return $this; + } + + $this->filtervalues[] = $fullvalue; + + return $this; + } +} diff --git a/lib/table/classes/local/filter/string_filter.php b/lib/table/classes/local/filter/string_filter.php new file mode 100644 index 00000000000..e9265b2aedb --- /dev/null +++ b/lib/table/classes/local/filter/string_filter.php @@ -0,0 +1,65 @@ +. + +/** + * String filter. + * + * @package core + * @category table + * @copyright 2020 Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use TypeError; + +/** + * Class representing a string filter. + * + * @package core + * @copyright 2020 Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class string_filter extends filter { + /** + * Add a value to the filter. + * + * @param string $values + * @return self + */ + public function add_filter_value($value): parent { + if (!is_string($value)) { + $type = gettype($value); + if ($type === 'object') { + $type = get_class($value); + } + + throw new TypeError("The value supplied was of type '{$type}'. A string was expected."); + } + + if (array_search($value, $this->filtervalues) !== false) { + // Remove duplicates. + return $this; + } + + $this->filtervalues[] = $value; + + return $this; + } +} diff --git a/lib/table/tests/coverage.php b/lib/table/tests/coverage.php new file mode 100644 index 00000000000..518bdc094c7 --- /dev/null +++ b/lib/table/tests/coverage.php @@ -0,0 +1,50 @@ +. + +declare(strict_types=1); + +defined('MOODLE_INTERNAL') || die(); + +/** + * Coverage information for the core_table component. + * + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * Coverage information for the core_table subsystem. + * + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +return new class extends phpunit_coverage_info { + /** @var array The list of folders relative to the plugin root to whitelist in coverage generation. */ + protected $whitelistfolders = [ + 'classes', + ]; + + /** @var array The list of files relative to the plugin root to whitelist in coverage generation. */ + protected $whitelistfiles = []; + + /** @var array The list of folders relative to the plugin root to excludelist in coverage generation. */ + protected $excludelistfolders = []; + + /** @var array The list of files relative to the plugin root to excludelist in coverage generation. */ + protected $excludelistfiles = []; +}; diff --git a/lib/table/tests/local/filter/filter_test.php b/lib/table/tests/local/filter/filter_test.php new file mode 100644 index 00000000000..c9d8a4a680e --- /dev/null +++ b/lib/table/tests/local/filter/filter_test.php @@ -0,0 +1,353 @@ +. + +/** + * Unit tests for core_table\local\filter\filter. + * + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use advanced_testcase; +use InvalidArgumentException; + +/** + * Unit tests for core_table\local\filter\filter. + * + * @coversDefaultClass \core_table\local\filter\filter + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filter_test extends advanced_testcase { + /** + * Test that the constructor correctly handles a number of conditions. + * + * @dataProvider constructor_provider + * @param array $args + * @param int $jointype + * @param array $values + */ + public function test_constructor(array $args, int $jointype, array $values): void { + $filter = new filter(...$args); + + // We should always get a filter. + $this->assertInstanceOf(filter::class, $filter); + + // We should always get the correct join type. + $this->assertEquals($jointype, $filter->get_join_type()); + + // The values should be the expected ones. + $this->assertSame($values, $filter->get_filter_values()); + } + + /** + * Data provider for the constructor providing a range of valid constructor arguments. + * + * @return array + */ + public function constructor_provider(): array { + return [ + 'Name without values' => [['keyword'], filter::JOINTYPE_DEFAULT, []], + 'Name with valid join type ANY' => [[ + 'keyword', + filter::JOINTYPE_ANY, + ], filter::JOINTYPE_ANY, []], + 'Name with valid join type ALL' => [[ + 'keyword', + filter::JOINTYPE_ALL, + ], filter::JOINTYPE_ALL, []], + 'Name with valid join type NONE' => [[ + 'keyword', + filter::JOINTYPE_NONE, + ], filter::JOINTYPE_NONE, []], + 'Name, no join type, with set of values' => [ + [ + 'keyword', + null, + [ + 's1', + 'janine', + ], + ], + filter::JOINTYPE_DEFAULT, + [ + 'janine', + 's1', + ], + ], + 'Name, and ANY, with set of values' => [ + [ + 'keyword', + filter::JOINTYPE_ANY, + [ + 's1', + 'kevin', + 'james', + 'janine', + ], + ], + filter::JOINTYPE_ANY, + [ + 'james', + 'janine', + 'kevin', + 's1', + ], + ], + 'Name, and ANY, with set of values which contains duplicates' => [ + [ + 'keyword', + filter::JOINTYPE_ANY, + [ + 's1', + 'kevin', + 'james', + 'janine', + 'kevin', + ], + ], + filter::JOINTYPE_ANY, + [ + 'james', + 'janine', + 'kevin', + 's1', + ], + ], + ]; + } + + /** + * Test that the constructor throws a relevant exception when passed an invalid join. + * + * @dataProvider constructor_invalid_join_provider + * @param mixed $jointype + */ + public function test_constructor_invalid_joins($jointype): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid join type specified'); + + new filter('invalid', $jointype); + } + + /** + * Data provider for the constructor providing a range of invalid join types to the constructor. + * + * @return array + */ + public function constructor_invalid_join_provider(): array { + return [ + 'Too low' => [-1], + 'Too high' => [4], + ]; + } + + /** + * Enusre that adding filter values works as expected. + */ + public function test_add_filter_value(): void { + $filter = new filter('example'); + + // Initially an empty list. + $this->assertEmpty($filter->get_filter_values()); + + // Adding null should do nothing. + $filter->add_filter_value(null); + $this->assertEmpty($filter->get_filter_values()); + + // Adding empty string should do nothing. + $filter->add_filter_value(''); + $this->assertEmpty($filter->get_filter_values()); + + // Adding a value should return that value. + $filter->add_filter_value('rosie'); + $this->assertSame([ + 'rosie', + ], $filter->get_filter_values()); + + // Adding a second value should add that value. + // The values should sorted. + $filter->add_filter_value('arthur'); + $this->assertSame([ + 'arthur', + 'rosie', + ], $filter->get_filter_values()); + + // Adding a duplicate value should not lead to that value being added again. + $filter->add_filter_value('arthur'); + $this->assertSame([ + 'arthur', + 'rosie', + ], $filter->get_filter_values()); + } + + /** + * Ensure that it is possibly to set the join type. + */ + public function test_set_join_type(): void { + $filter = new filter('example'); + + // Initial set with the default type should just work. + // The setter should be chainable. + $this->assertEquals($filter, $filter->set_join_type(filter::JOINTYPE_DEFAULT)); + $this->assertEquals(filter::JOINTYPE_DEFAULT, $filter->get_join_type()); + + // It should be possible to update the join type later. + $this->assertEquals($filter, $filter->set_join_type(filter::JOINTYPE_NONE)); + $this->assertEquals(filter::JOINTYPE_NONE, $filter->get_join_type()); + + $this->assertEquals($filter, $filter->set_join_type(filter::JOINTYPE_ANY)); + $this->assertEquals(filter::JOINTYPE_ANY, $filter->get_join_type()); + + $this->assertEquals($filter, $filter->set_join_type(filter::JOINTYPE_ALL)); + $this->assertEquals(filter::JOINTYPE_ALL, $filter->get_join_type()); + } + + /** + * Ensure that it is not possible to provide a value out of bounds when setting the join type. + */ + public function test_set_join_type_invalid_low(): void { + $filter = new filter('example'); + + // Valid join types are current 0, 1, or 2. + // A value too low should be rejected. + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid join type specified"); + $filter->set_join_type(-1); + } + + /** + * Ensure that it is not possible to provide a value out of bounds when setting the join type. + */ + public function test_set_join_type_invalid_high(): void { + $filter = new filter('example'); + + // Valid join types are current 0, 1, or 2. + // A value too low should be rejected. + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid join type specified"); + $filter->set_join_type(4); + } + + /** + * Ensure that the name getter is callable. + */ + public function test_get_name(): void { + $filter = new filter('examplename'); + + $this->assertEquals('examplename', $filter->get_name()); + } + + /** + * Data provider for the countable tests. + * + * @return array + */ + public function filter_value_provider(): array { + return [ + 'Empty' => [[], 0], + 'Single value' => [[10], 1], + 'Single repeated value' => [[10, 10, 10, 10], 1], + 'Multiple values, no repeats' => [[1, 2, 3, 4, 5], 5], + 'Multiple values, including repeats' => [[1, 2, 1, 3, 1, 3, 4, 1, 5], 5], + ]; + } + + /** + * Ensure that the filter is countable. + * + * @dataProvider filter_value_provider + * @param array $values List of context IDs + * @param int $count Expected count + */ + public function test_countable($values, $count): void { + $filter = new filter('example', null, $values); + + $this->assertCount($count, $filter); + } + + /** + * Ensure that the contextlist_base iterates over the set of contexts. + */ + public function test_filter_iteration(): void { + $filter = new filter('example'); + + // The iterator position should be at the start. + $this->assertEquals(0, $filter->key()); + + foreach ($filter as $filtervalue) { + // This should not be called. + $this->assertFalse(true); + } + + // The iterator position should still be at the start. + $this->assertEquals(0, $filter->key()); + + // Adding filter values should cause the values in the Iterator to be sorted. + $filter = new filter('example'); + $filter->add_filter_value(6); + $filter->add_filter_value(5); + $filter->add_filter_value(4); + $filter->add_filter_value(3); + $filter->add_filter_value(2); + + // The iterator position should be at the start after adding values. + $this->assertEquals(0, $filter->key()); + + $foundvalues = []; + foreach ($filter as $filtervalue) { + $foundvalues[] = $filtervalue; + } + + $this->assertEquals([2, 3, 4, 5, 6], $foundvalues); + + // The iterator position should now be at position 5. + // The position is automatically updated prior to moving. + $this->assertEquals(5, $filter->key()); + + // Adding another value shoudl cause the Iterator to be re-sorted. + $filter->add_filter_value(1); + + // The iterator position should be at the start after adding values. + $this->assertEquals(0, $filter->key()); + + $foundvalues = []; + foreach ($filter as $filtervalue) { + $foundvalues[] = $filtervalue; + } + + $this->assertEquals([1, 2, 3, 4, 5, 6], $foundvalues); + + // The iterator position should now be at position 6. + $this->assertEquals(6, $filter->key()); + } + + /** + * Tests for the count function of a filter. + */ + public function test_filter_current(): void { + $filter = new filter('example', null, [42]); + $this->assertEquals(42, $filter->current()); + } +} diff --git a/lib/table/tests/local/filter/filterset_test.php b/lib/table/tests/local/filter/filterset_test.php new file mode 100644 index 00000000000..4722c6e72c3 --- /dev/null +++ b/lib/table/tests/local/filter/filterset_test.php @@ -0,0 +1,548 @@ +. + +/** + * Unit tests for core_table\local\filter\filterset. + * + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use InvalidArgumentException; +use UnexpectedValueException; +use advanced_testcase; +use moodle_exception; + +/** + * Unit tests for core_table\local\filter\filterset. + * + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class filterset_test extends advanced_testcase { + /** + * Ensure that it is possibly to set the join type. + */ + public function test_set_join_type(): void { + $filterset = $this->get_mocked_filterset(); + + // Initial set with the default type should just work. + // The setter should be chainable. + $this->assertEquals($filterset, $filterset->set_join_type(filterset::JOINTYPE_DEFAULT)); + $this->assertEquals(filterset::JOINTYPE_DEFAULT, $filterset->get_join_type()); + + // It should be possible to update the join type later. + $this->assertEquals($filterset, $filterset->set_join_type(filterset::JOINTYPE_NONE)); + $this->assertEquals(filterset::JOINTYPE_NONE, $filterset->get_join_type()); + + $this->assertEquals($filterset, $filterset->set_join_type(filterset::JOINTYPE_ANY)); + $this->assertEquals(filterset::JOINTYPE_ANY, $filterset->get_join_type()); + + $this->assertEquals($filterset, $filterset->set_join_type(filterset::JOINTYPE_ALL)); + $this->assertEquals(filterset::JOINTYPE_ALL, $filterset->get_join_type()); + } + + /** + * Ensure that it is not possible to provide a value out of bounds when setting the join type. + */ + public function test_set_join_type_invalid_low(): void { + $filterset = $this->get_mocked_filterset(); + + // Valid join types are current 0, 1, or 2. + // A value too low should be rejected. + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid join type specified"); + $filterset->set_join_type(-1); + } + + /** + * Ensure that it is not possible to provide a value out of bounds when setting the join type. + */ + public function test_set_join_type_invalid_high(): void { + $filterset = $this->get_mocked_filterset(); + + // Valid join types are current 0, 1, or 2. + // A value too low should be rejected. + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("Invalid join type specified"); + $filterset->set_join_type(4); + } + + /** + * Ensure that adding filter values works as expected. + */ + public function test_add_filter_value(): void { + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'name' => filter::class, + 'species' => filter::class, + ])); + + // Initially an empty list. + $this->assertEmpty($filterset->get_filters()); + + // Test data. + $speciesfilter = new filter('species', null, ['canine']); + $namefilter = new filter('name', null, ['rosie']); + + // Add a filter to the list. + $filterset->add_filter($speciesfilter); + $this->assertSame([ + $speciesfilter, + ], array_values($filterset->get_filters())); + + // Adding a second value should add that value. + // The values should sorted. + $filterset->add_filter($namefilter); + $this->assertSame([ + $namefilter, + $speciesfilter, + ], array_values($filterset->get_filters())); + + // Adding an existing filter again should be ignored. + $filterset->add_filter($speciesfilter); + $this->assertSame([ + $namefilter, + $speciesfilter, + ], array_values($filterset->get_filters())); + } + + /** + * Ensure that it is possible to add a filter of a validated filter type. + */ + public function test_add_filter_validated_type(): void { + $namefilter = $this->getMockBuilder(filter::class) + ->setConstructorArgs(['name']) + ->setMethods(null) + ->getMock(); + $namefilter->add_filter_value('rosie'); + + // Mock the get_optional_filters function. + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'name' => get_class($namefilter), + ])); + + // Add a filter to the list. + // This is the 'name' filter. + $filterset->add_filter($namefilter); + + $this->assertNull($filterset->check_validity()); + } + + /** + * Ensure that it is not possible to add a type which is not expected. + */ + public function test_add_filter_unexpected_key(): void { + // Mock the get_optional_filters function. + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([])); + + // Add a filter to the list. + // This is the 'name' filter. + $namefilter = new filter('name'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The filter 'name' was not recognised."); + $filterset->add_filter($namefilter); + } + + /** + * Ensure that it is not possible to add a validated type where the type is incorrect. + */ + public function test_add_filter_validated_type_incorrect(): void { + $filtername = "name"; + $otherfilter = $this->createMock(filter::class); + + // Mock the get_optional_filters function. + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + $filtername => get_class($otherfilter), + ])); + + // Add a filter to the list. + // This is the 'name' filter. + $namefilter = $this->getMockBuilder(filter::class) + ->setMethods(null) + ->setConstructorArgs([$filtername]) + ->getMock(); + + $actualtype = get_class($namefilter); + $requiredtype = get_class($otherfilter); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "The filter '{$filtername}' was incorrectly specified as a {$actualtype}. It must be a {$requiredtype}." + ); + $filterset->add_filter($namefilter); + } + + /** + * Ensure that a filter can be added from parameters provided to a web service. + */ + public function test_add_filter_from_params(): void { + $filtername = "name"; + $otherfilter = $this->getMockBuilder(filter::class) + ->setMethods(null) + ->setConstructorArgs([$filtername]) + ->getMock(); + + // Mock the get_optional_filters function. + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + $filtername => get_class($otherfilter), + ])); + + $result = $filterset->add_filter_from_params($filtername, filter::JOINTYPE_DEFAULT, ['kevin']); + + // The function is chainable. + $this->assertEquals($filterset, $result); + + // Get the filter back. + $filter = $filterset->get_filter($filtername); + $this->assertEquals($filtername, $filter->get_name()); + $this->assertEquals(filter::JOINTYPE_DEFAULT, $filter->get_join_type()); + $this->assertEquals(['kevin'], $filter->get_filter_values()); + } + + /** + * Ensure that an unknown filter is not added. + */ + public function test_add_filter_from_params_unable_to_autoload(): void { + // Mock the get_optional_filters function. + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'name' => '\\moodle\\this\\is\\a\\fake\\class\\name', + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + "The filter class '\\moodle\\this\\is\\a\\fake\\class\\name' for filter 'name' could not be found." + ); + $filterset->add_filter_from_params('name', filter::JOINTYPE_DEFAULT, ['kevin']); + } + + /** + * Ensure that an unknown filter is not added. + */ + public function test_add_filter_from_params_invalid(): void { + $filtername = "name"; + $otherfilter = $this->getMockBuilder(filter::class) + ->setMethods(null) + ->setConstructorArgs([$filtername]) + ->getMock(); + + // Mock the get_optional_filters function. + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + $filtername => get_class($otherfilter), + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage("The filter 'unknownfilter' was not recognised."); + $filterset->add_filter_from_params('unknownfilter', filter::JOINTYPE_DEFAULT, ['kevin']); + } + + /** + * Ensure that adding a different filter with a different object throws an Exception. + */ + public function test_duplicate_filter_value(): void { + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'name' => filter::class, + 'species' => filter::class, + ])); + + // Add a filter to the list. + // This is the 'name' filter. + $namefilter = new filter('name', null, ['rosie']); + $filterset->add_filter($namefilter); + + // Add another filter to the list. + // This one has been incorrectly called the 'name' filter when it should be 'species'. + $this->expectException(UnexpectedValueException::Class); + $this->expectExceptionMessage("A filter of type 'name' has already been added. Check that you have the correct filter."); + + $speciesfilter = new filter('name', null, ['canine']); + $filterset->add_filter($speciesfilter); + } + + /** + * Ensure that validating a filterset correctly compares filter types. + */ + public function test_check_validity_optional_filters_not_specified(): void { + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'name' => filter::class, + 'species' => filter::class, + ])); + + $this->assertNull($filterset->check_validity()); + } + + /** + * Ensure that validating a filterset correctly requires required filters. + */ + public function test_check_validity_required_filter(): void { + $filterset = $this->get_mocked_filterset(['get_required_filters']); + $filterset->expects($this->any()) + ->method('get_required_filters') + ->willReturn([ + 'name' => filter::class + ]); + + // Add a filter to the list. + // This is the 'name' filter. + $filterset->add_filter(new filter('name')); + + $this->assertNull($filterset->check_validity()); + } + + /** + * Ensure that validating a filterset excepts correctly when a required fieldset is missing. + */ + public function test_check_validity_filter_missing_required(): void { + $filterset = $this->get_mocked_filterset(['get_required_filters']); + $filterset->expects($this->any()) + ->method('get_required_filters') + ->willReturn([ + 'name' => filter::class, + 'species' => filter::class, + ]); + + $this->expectException(moodle_exception::Class); + $this->expectExceptionMessage("One or more required filters were missing (name, species)"); + $filterset->check_validity(); + } + + /** + * Ensure that getting the filters returns a sorted list of filters. + */ + public function test_get_filters(): void { + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + // Filters are not defined lexically. + 'd' => filter::class, + 'b' => filter::class, + 'a' => filter::class, + 'c' => filter::class, + ])); + + // Filters are added in a different non-lexical order. + $c = new filter('c'); + $filterset->add_filter($c); + + $b = new filter('b'); + $filterset->add_filter($b); + + $d = new filter('d'); + $filterset->add_filter($d); + + $a = new filter('a'); + $filterset->add_filter($a); + + // But they are returned lexically sorted. + $this->assertEquals([ + 'a' => $a, + 'b' => $b, + 'c' => $c, + 'd' => $d, + ], $filterset->get_filters()); + } + + /** + * Ensure that getting a singlel filter returns the correct filter. + */ + public function test_get_filter(): void { + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + // Filters are not defined lexically. + 'd' => filter::class, + 'b' => filter::class, + 'a' => filter::class, + 'c' => filter::class, + ])); + + // Filters are added in a different non-lexical order. + $c = new filter('c'); + $filterset->add_filter($c); + + $b = new filter('b'); + $filterset->add_filter($b); + + $d = new filter('d'); + $filterset->add_filter($d); + + $a = new filter('a'); + $filterset->add_filter($a); + + // Filters can be individually retrieved in any order. + $this->assertEquals($d, $filterset->get_filter('d')); + $this->assertEquals($a, $filterset->get_filter('a')); + $this->assertEquals($b, $filterset->get_filter('b')); + $this->assertEquals($c, $filterset->get_filter('c')); + } + + /** + * Ensure that it is not possible to retrieve an unknown filter. + */ + public function test_get_filter_unknown(): void { + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'a' => filter::class, + ])); + + $a = new filter('a'); + $filterset->add_filter($a); + + $this->expectException(UnexpectedValueException::Class); + $this->expectExceptionMessage("The filter specified (d) is invalid."); + $filterset->get_filter('d'); + } + + /** + * Ensure that it is not possible to retrieve a valid filter before it is created. + */ + public function test_get_filter_not_yet_added(): void { + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'a' => filter::class, + ])); + + $this->expectException(UnexpectedValueException::Class); + $this->expectExceptionMessage("The filter specified (a) has not been created."); + $filterset->get_filter('a'); + } + + /** + * Ensure that the get_all_filtertypes function correctly returns the combined filterset. + */ + public function test_get_all_filtertypes(): void { + $otherfilter = $this->createMock(filter::class); + + $filterset = $this->get_mocked_filterset([ + 'get_optional_filters', + 'get_required_filters', + ]); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'a' => filter::class, + 'c' => get_class($otherfilter), + ])); + $filterset->method('get_required_filters') + ->will($this->returnValue([ + 'b' => get_class($otherfilter), + 'd' => filter::class, + ])); + + $this->assertEquals([ + 'a' => filter::class, + 'b' => get_class($otherfilter), + 'c' => get_class($otherfilter), + 'd' => filter::class, + ], $filterset->get_all_filtertypes()); + } + + /** + * Ensure that the get_all_filtertypes function correctly returns the combined filterset. + */ + public function test_get_all_filtertypes_conflict(): void { + $otherfilter = $this->createMock(filter::class); + + $filterset = $this->get_mocked_filterset([ + 'get_optional_filters', + 'get_required_filters', + ]); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + 'a' => filter::class, + 'b' => get_class($otherfilter), + 'd' => filter::class, + ])); + $filterset->method('get_required_filters') + ->will($this->returnValue([ + 'b' => get_class($otherfilter), + 'c' => filter::class, + 'd' => filter::class, + ])); + + $this->expectException(InvalidArgumentException::Class); + $this->expectExceptionMessage("Some filter types are both required, and optional: b, d"); + $filterset->get_all_filtertypes(); + + } + + /** + * Ensure that the has_filter function works as expected. + */ + public function test_has_filter(): void { + $filterset = $this->get_mocked_filterset(['get_optional_filters']); + $filterset->method('get_optional_filters') + ->will($this->returnValue([ + // Define filters 'a', and 'b'. + 'a' => filter::class, + 'b' => filter::class, + ])); + + // Only add filter 'a'. + $a = new filter('a'); + $filterset->add_filter($a); + + // Filter 'a' should exist. + $this->assertTrue($filterset->has_filter('a')); + + // Filter 'b' is defined, but has not been added. + $this->assertFalse($filterset->has_filter('b')); + + // Filter 'c' is not defined. + // No need to throw any kind of exception - this is an existence check. + $this->assertFalse($filterset->has_filter('c')); + } + + /** + * Get a mocked copy of the filterset, mocking the specified methods. + * + * @param array $mockedmethods anonymous array containing the list of mocked methods + * @return filterset Mock of the filterset + */ + protected function get_mocked_filterset(array $mockedmethods = null): filterset { + if (empty($mockedmethods)) { + $mockedmethods = null; + } + + return $this->getMockForAbstractClass(filterset::class, [], '', true, true, true, $mockedmethods); + } +} diff --git a/lib/table/tests/local/filter/integer_filter_test.php b/lib/table/tests/local/filter/integer_filter_test.php new file mode 100644 index 00000000000..caf0027155b --- /dev/null +++ b/lib/table/tests/local/filter/integer_filter_test.php @@ -0,0 +1,109 @@ +. + +/** + * Unit tests for core_table\local\filter\filter. + * + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use advanced_testcase; +use TypeError; + +/** + * Unit tests for core_table\local\filter\integer_filter. + * + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class integer_filter_test extends advanced_testcase { + /** + * Ensure that the add_filter_value function works as expected with valid values. + */ + public function test_add_filter_value_int(): void { + $filter = new integer_filter('example'); + + // Initially an empty list. + $this->assertEmpty($filter->get_filter_values()); + + // Adding a value should return that value. + $filter->add_filter_value(10); + $this->assertSame([ + 10, + ], $filter->get_filter_values()); + + // Adding a second value should add that value. + // The values should sorted. + $filter->add_filter_value(2); + $this->assertSame([ + 2, + 10, + ], $filter->get_filter_values()); + + // Adding a duplicate value should not lead to that value being added again. + $filter->add_filter_value(10); + $this->assertSame([ + 2, + 10, + ], $filter->get_filter_values()); + } + + /** + * Ensure that the add_filter_value function rejects invalid types. + * + * @dataProvider add_filter_value_invalid_types_provider + * @param mixed $value + * @param string $type + */ + public function test_add_filter_value_type_invalid($value, string $type): void { + $filter = new integer_filter('example'); + + // Adding empty string is not supported. + $this->expectException(TypeError::class); + $this->expectExceptionMessage("The value supplied was of type '{$type}'. An integer was expected."); + $filter->add_filter_value($value); + } + + /** + * Data provider for add_filter_value tests with invalid types. + * + * @return array + */ + public function add_filter_value_invalid_types_provider(): array { + return [ + 'Null' => [null, 'NULL'], + 'Empty string' => ['', 'string'], + 'Filled string' => ['example', 'string'], + 'Float 1.0' => [1.0, 'double'], + 'Float 1.1' => [1.1, 'double'], + 'bool' => [false, 'boolean'], + 'array' => [[], 'array'], + 'stdClass' => [(object) [], 'stdClass'], + + // Note: The comparison value will be a fully-qualfied class name. + 'Class' => [new filter('example'), filter::class], + ]; + } +} diff --git a/lib/table/tests/local/filter/numeric_comparison_filter_test.php b/lib/table/tests/local/filter/numeric_comparison_filter_test.php new file mode 100644 index 00000000000..af8ac564056 --- /dev/null +++ b/lib/table/tests/local/filter/numeric_comparison_filter_test.php @@ -0,0 +1,280 @@ +. + +/** + * Unit tests for core_table\local\filter\numeric_comparison_filter. + * + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use advanced_testcase; +use InvalidArgumentException; +use TypeError; + +/** + * Unit tests for core_table\local\filter\numeric_comparison_filter. + * + * @package core_table + * @category test + * @copyright 2020 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class numeric_comparison_filter_test extends advanced_testcase { + /** + * Ensure that the add_filter_value function works as expected with valid values. + */ + public function test_add_filter_value_valid(): void { + $filter = new numeric_comparison_filter('example'); + + // Initially an empty list. + $this->assertEmpty($filter->get_filter_values()); + + // Adding a value should return that value. + $filter->add_filter_value(json_encode((object) [ + 'direction' => '>', + 'value' => 100, + ])); + $this->assertEquals([ + (object) [ + 'direction' => '>', + 'value' => 100, + ], + ], $filter->get_filter_values()); + + // Adding a second value should add that value. + // The values should sorted. + $filter->add_filter_value(json_encode((object) [ + 'direction' => '<=', + 'value' => 1000, + ])); + $this->assertEquals([ + (object) [ + 'direction' => '<=', + 'value' => 1000, + ], + (object) [ + 'direction' => '>', + 'value' => 100, + ], + ], $filter->get_filter_values()); + + // Adding a duplicate value should not lead to that value being added again. + $filter->add_filter_value(json_encode((object) [ + 'direction' => '>', + 'value' => 100, + ])); + $this->assertEquals([ + (object) [ + 'direction' => '<=', + 'value' => 1000, + ], + (object) [ + 'direction' => '>', + 'value' => 100, + ], + ], $filter->get_filter_values()); + } + + /** + * Ensure that the add_filter_value function rejects invalid types. + * + * @dataProvider add_filter_value_invalid_types_provider + * @param mixed $values + * @param string $exceptiontype + * @param string $exceptionmessage + */ + public function test_add_filter_value_type_invalid($values, string $exceptiontype, string $exceptionmessage): void { + $filter = new numeric_comparison_filter('example'); + + // Adding empty string is not supported. + $this->expectException($exceptiontype); + $this->expectExceptionMessage($exceptionmessage); + call_user_func_array([$filter, 'add_filter_value'], $values); + } + + /** + * Data provider for add_filter_value tests with invalid types. + * + * @return array + */ + public function add_filter_value_invalid_types_provider(): array { + return [ + 'Null' => [ + [null], + TypeError::class, + "The value supplied was of type 'NULL'. A string representing a json-encoded value was expected.", + ], + 'Single value string' => [ + [''], + InvalidArgumentException::class, + "A json-encoded object containing both a direction, and comparison value was expected.", + ], + 'Single value integer' => [ + [42], + TypeError::class, + "The value supplied was of type 'integer'. A string representing a json-encoded value was expected.", + ], + 'Single value float' => [ + [4.2], + TypeError::class, + "The value supplied was of type 'double'. A string representing a json-encoded value was expected.", + ], + 'Single value bool' => [ + [false], + TypeError::class, + "The value supplied was of type 'boolean'. A string representing a json-encoded value was expected.", + ], + 'Single value array' => [ + [[]], + TypeError::class, + "The value supplied was of type 'array'. A string representing a json-encoded value was expected.", + ], + 'Single value object' => [ + [(object) []], + TypeError::class, + "The value supplied was of type 'stdClass'. A string representing a json-encoded value was expected.", + ], + 'Single value class' => [ + [new filter('example')], + TypeError::class, + "The value supplied was of type '" . filter::class . "'. A string representing a json-encoded value was expected.", + ], + + 'json-encoded single value null' => [ + // Note a json-encoded null is the stringy 'null'. + [json_encode(null)], + InvalidArgumentException::class, + "A json-encoded object containing both a direction, and comparison value was expected.", + ], + 'json-encoded single value string' => [ + [json_encode('')], + InvalidArgumentException::class, + "The value supplied was a json encoded 'string'. " . + "An object containing both a direction, and comparison value was expected.", + ], + 'json-encoded single value integer' => [ + [json_encode(42)], + InvalidArgumentException::class, + "The value supplied was a json encoded 'string'. " . + "An object containing both a direction, and comparison value was expected.", + ], + 'json-encoded single value double' => [ + [json_encode(4.2)], + InvalidArgumentException::class, + "The value supplied was a json encoded 'string'. " . + "An object containing both a direction, and comparison value was expected.", + ], + 'json-encoded single value bool' => [ + [json_encode(false)], + InvalidArgumentException::class, + "The value supplied was a json encoded 'string'. " . + "An object containing both a direction, and comparison value was expected.", + ], + 'json-encoded single value array' => [ + [json_encode([])], + InvalidArgumentException::class, + "The value supplied was a json encoded 'string'. " . + "An object containing both a direction, and comparison value was expected.", + ], + + 'json-encoded empty object' => [ + [json_encode((object) [])], + InvalidArgumentException::class, + "A 'direction' must be provided.", + ], + 'json-encoded single value class' => [ + // A class will contain any public properties when json-encoded. It is treated in the same was a stdClass. + [json_encode(new filter('example'))], + InvalidArgumentException::class, + "A 'direction' must be provided.", + ], + + 'Direction provided, value missing' => [ + [json_encode([ + 'direction' => '>', + ])], + InvalidArgumentException::class, + "A 'value' must be provided.", + ], + + 'Direction invalid +' => [ + [json_encode([ + 'direction' => '+', + 'value' => 100, + ])], + InvalidArgumentException::class, + "Invalid direction specified '+'." + ], + 'Direction invalid -' => [ + [json_encode([ + 'direction' => '-', + 'value' => 100, + ])], + InvalidArgumentException::class, + "Invalid direction specified '-'." + ], + + 'Value string' => [ + [json_encode([ + 'direction' => '>', + 'value' => "example", + ])], + TypeError::class, + "The value supplied was of type 'string'. A numeric value was expected." + ], + 'Value bool' => [ + [json_encode([ + 'direction' => '>', + 'value' => false, + ])], + TypeError::class, + "The value supplied was of type 'boolean'. A numeric value was expected." + ], + 'Value array' => [ + [json_encode([ + 'direction' => '>', + 'value' => [], + ])], + TypeError::class, + "The value supplied was of type 'array'. A numeric value was expected." + ], + 'Value stdClass' => [ + [json_encode([ + 'direction' => '>', + 'value' => (object) [], + ])], + TypeError::class, + "The value supplied was of type 'stdClass'. A numeric value was expected." + ], + 'Value class' => [ + // A class will contain any public properties when json-encoded. It is treated in the same was a stdClass. + [json_encode([ + 'direction' => '>', + 'value' => new filter('example'), + ])], + TypeError::class, + "The value supplied was of type 'stdClass'. A numeric value was expected." + ], + ]; + } +} diff --git a/lib/table/tests/local/filter/string_filter_test.php b/lib/table/tests/local/filter/string_filter_test.php new file mode 100644 index 00000000000..7a092bb80e0 --- /dev/null +++ b/lib/table/tests/local/filter/string_filter_test.php @@ -0,0 +1,109 @@ +. + +/** + * Unit tests for core_table\local\filter\string_filter. + * + * @package core_table + * @category test + * @copyright 2020 Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types=1); + +namespace core_table\local\filter; + +use advanced_testcase; +use TypeError; + +/** + * Unit tests for core_table\local\filter\string_filter. + * + * @package core_table + * @category test + * @copyright 2020 Simey Lameze + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class string_filter_test extends advanced_testcase { + /** + * Ensure that the add_filter_value function works as expected with valid values. + */ + public function test_add_filter_value_string(): void { + $filter = new string_filter('example'); + + // Initially an empty list. + $this->assertEmpty($filter->get_filter_values()); + + // Adding a value should return that value. + $filter->add_filter_value('apple'); + $this->assertSame([ + 'apple', + ], $filter->get_filter_values()); + + // Adding a second value should add that value. + // The values should sorted. + $filter->add_filter_value('pear'); + $this->assertSame([ + 'apple', + 'pear', + ], $filter->get_filter_values()); + + // Adding a duplicate value should not lead to that value being added again. + $filter->add_filter_value('apple'); + $this->assertSame([ + 'apple', + 'pear', + ], $filter->get_filter_values()); + } + + /** + * Ensure that the add_filter_value function rejects invalid types. + * + * @dataProvider add_filter_value_invalid_types_provider + * @param mixed $value + * @param string $type + */ + public function test_add_filter_value_type_invalid($value, string $type): void { + $filter = new string_filter('example'); + + // Adding empty string is not supported. + $this->expectException(TypeError::class); + $this->expectExceptionMessage("The value supplied was of type '{$type}'. A string was expected."); + $filter->add_filter_value($value); + } + + /** + * Data provider for add_filter_value tests with invalid types. + * + * @return array + */ + public function add_filter_value_invalid_types_provider(): array { + return [ + 'Null' => [null, 'NULL'], + '1' => [1, 'integer'], + '2' => [2, 'integer'], + 'Float 1.0' => [1.0, 'double'], + 'Float 1.1' => [1.1, 'double'], + 'bool' => [false, 'boolean'], + 'array' => [[], 'array'], + 'stdClass' => [(object) [], 'stdClass'], + + // Note: The comparison value will be a fully-qualified class name. + 'Class' => [new filter('example'), filter::class], + ]; + } +}