diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 4d630c7..5d5ae3d 100644 --- a/lib/AbstractException.php +++ b/lib/AbstractException.php @@ -6,6 +6,8 @@ abstract class AbstractException extends \Exception { const CODES = [ "Exception.uncoded" => -1, "Exception.unknown" => 10000, + "ExceptionType.strictFailure" => 10011, + "ExceptionType.typeUnknown" => 10012, "Lang/Exception.defaultFileMissing" => 10101, "Lang/Exception.fileMissing" => 10102, "Lang/Exception.fileUnreadable" => 10103, diff --git a/lib/ExceptionType.php b/lib/ExceptionType.php new file mode 100644 index 0000000..16094ce --- /dev/null +++ b/lib/ExceptionType.php @@ -0,0 +1,6 @@ + ["!Y-m-d\TH:i:s", "Y-m-d\TH:i:s\Z" ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets + 'iso8601m' => ["!Y-m-d\TH:i:s.u", "Y-m-d\TH:i:s.u\Z" ], // NOTE: ISO 8601 dates require special input processing because of varying formats for timezone offsets + 'microtime' => ["U.u", "0.u00 U" ], // NOTE: the actual input format at the user level matches the output format; pre-processing is required for PHP not to fail + 'http' => ["!D, d M Y H:i:s \G\M\T", "D, d M Y H:i:s \G\M\T"], + 'sql' => ["!Y-m-d H:i:s", "Y-m-d H:i:s" ], + 'date' => ["!Y-m-d", "Y-m-d" ], + 'time' => ["!H:i:s", "H:i:s" ], + 'unix' => ["U", "U" ], + 'float' => ["U.u", "U.u" ], + ]; + public static function transform($date, string $outFormat = null, string $inFormat = null, bool $inLocal = false) { $date = self::normalize($date, $inFormat, $inLocal); if (is_null($date) || is_null($outFormat)) { @@ -14,7 +26,7 @@ class Date { } switch ($outFormat) { case 'http': $f = "D, d M Y H:i:s \G\M\T"; break; - case 'iso8601': $f = "Y-m-d\TH:i:s"; break; + case 'iso8601': $f = "Y-m-d\TH:i:s"; break; case 'sql': $f = "Y-m-d H:i:s"; break; case 'date': $f = "Y-m-d"; break; case 'time': $f = "H:i:s"; break; @@ -36,7 +48,7 @@ class Date { if (!is_null($inFormat)) { switch ($inFormat) { case 'http': $f = "D, d M Y H:i:s \G\M\T"; break; - case 'iso8601': $f = "Y-m-d\TH:i:sP"; break; + case 'iso8601': $f = "Y-m-d\TH:i:sP"; break; case 'sql': $f = "Y-m-d H:i:s"; break; case 'date': $f = "Y-m-d"; break; case 'time': $f = "H:i:s"; break; diff --git a/lib/Misc/ValueInfo.php b/lib/Misc/ValueInfo.php index 040243d..09dca6d 100644 --- a/lib/Misc/ValueInfo.php +++ b/lib/Misc/ValueInfo.php @@ -2,6 +2,8 @@ declare(strict_types=1); namespace JKingWeb\Arsse\Misc; +use JKingWeb\Arsse\ExceptionType; + class ValueInfo { // universal const VALID = 1 << 0; @@ -9,9 +11,232 @@ class ValueInfo { // integers const ZERO = 1 << 2; const NEG = 1 << 3; + const FLOAT = 1 << 4; // strings const EMPTY = 1 << 2; const WHITE = 1 << 3; + //normalization types + const T_MIXED = 0; // pass through unchanged + const T_NULL = 1; // convert to null + const T_BOOL = 2; // convert to boolean + const T_INT = 3; // convert to integer + const T_FLOAT = 4; // convert to floating point + const T_DATE = 5; // convert to DateTimeInterface instance + const T_STRING = 6; // convert to string + const T_ARRAY = 7; // convert to array + //normalization modes + const M_NULL = 1 << 28; // pass nulls through regardless of target type + const M_DROP = 1 << 29; // drop the value (return null) if the type doesn't match + const M_STRICT = 1 << 30; // throw an exception if the type doesn't match + const M_ARRAY = 1 << 31; // the value should be a flat array of values of the specified type; indexed and associative are both acceptable + + public function normalize($value, int $type, string $dateFormat = null) { + $allowNull = ($type & self::M_NULL); + $strict = ($type & (self::M_STRICT | self::M_DROP)); + $drop = ($type & self::M_DROP); + $arrayVal = ($type & self::M_ARRAY); + $type = ($type & ~(self::M_NULL | self::M_DROP | self::M_STRICT | self::M_ARRAY)); + // if the value is null and this is allowed, simply return + if ($allowNull && is_null($value)) { + return null; + } + // if the value is supposed to be an array, handle it specially + if ($arrayVal) { + $value = self::normalize($value, self::T_ARRAY); + foreach ($value as $key => $v) { + $value[$key] = self::normalize($v, $type | ($allowNull ? self::M_NULL : 0) | ($strict ? self::M_STRICT : 0) | ($drop ? self::M_DROP : 0), $dateFormat); + } + return $value; + } + switch ($type) { + case self::T_MIXED: + return $value; + case self::T_NULL: + return null; + case self::T_BOOL: + if (is_bool($value)) { + return $value; + } + $out = self::bool($value); + if ($strict && is_null($out)) { + // if strict and input is not a boolean, this is an error + if ($drop) { + return null; + } + throw new ExceptionType("strictFailure", $type); + } elseif (is_float($value) && is_nan($value)) { + return false; + } elseif (is_null($out)) { + // if not strict and input is not a boolean, return a simple type-cast + return (bool) $value; + } + return $out; + case self::T_INT: + if (is_int($value)) { + return $value; + } elseif ($value instanceof \DateTimeInterface) { + if ($strict && !$drop) { + throw new ExceptionType("strictFailure", $type); + } + return (!$drop) ? (int) $value->getTimestamp(): null; + } + $info = self::int($value); + if ($strict && !($info & self::VALID)) { + // if strict and input is not an integer, this is an error + if ($drop) { + return null; + } + throw new ExceptionType("strictFailure", $type); + } elseif (is_bool($value)) { + return (int) $value; + } elseif ($info & (self::VALID | self::FLOAT)) { + $out = strtolower((string) $value); + if (strpos($out, "e")) { + return (int) (float) $out; + } else { + return (int) $out; + } + } else { + return 0; + } + case self::T_FLOAT: + if (is_float($value)) { + return $value; + } elseif ($value instanceof \DateTimeInterface) { + if ($strict && !$drop) { + throw new ExceptionType("strictFailure", $type); + } + return (!$drop) ? (float) $value->getTimestamp(): null; + } elseif (is_bool($value) && $strict) { + if ($drop) { + return null; + } + throw new ExceptionType("strictFailure", $type); + } + $out = filter_var($value, \FILTER_VALIDATE_FLOAT); + if ($strict && $out===false) { + // if strict and input is not a float, this is an error + if ($drop) { + return null; + } + throw new ExceptionType("strictFailure", $type); + } + return (float) $out; + case self::T_STRING: + if (is_string($value)) { + return $value; + } + if ($value instanceof \DateTimeImmutable) { + return $value->setTimezone(new \DateTimeZone("UTC"))->format(Date::FORMAT['iso8601'][1]); + } elseif ($value instanceof \DateTime) { + $out = clone $value; + $out->setTimezone(new \DateTimeZone("UTC")); + return $out->format(Date::FORMAT['iso8601'][1]); + } elseif (is_float($value) && is_finite($value)) { + $out = (string) $value; + if(!strpos($out, "E")) { + return $out; + } else { + $out = sprintf("%F", $value); + return substr($out, -2)==".0" ? (string) (int) $out : $out; + } + } + $info = self::str($value); + if (!($info & self::VALID)) { + if ($drop) { + return null; + } elseif ($strict) { + // if strict and input is not a string, this is an error + throw new ExceptionType("strictFailure", $type); + } elseif (!is_scalar($value)) { + return ""; + } else { + return (string) $value; + } + } else { + return (string) $value; + } + case self::T_DATE: + if ($value instanceof \DateTimeImmutable) { + return $value->setTimezone(new \DateTimeZone("UTC")); + } elseif ($value instanceof \DateTime) { + $out = clone $value; + $out->setTimezone(new \DateTimeZone("UTC")); + return $out; + } elseif (is_int($value)) { + return \DateTime::createFromFormat("U", (string) $value, new \DateTimeZone("UTC")); + } elseif (is_float($value)) { + return \DateTime::createFromFormat("U.u", sprintf("%F", $value), new \DateTimeZone("UTC")); + } elseif (is_string($value)) { + try { + if (!is_null($dateFormat)) { + $out = false; + if ($dateFormat=="microtime") { + // PHP is not able to correctly handle the output of microtime() as the input of DateTime::createFromFormat(), so we fudge it to look like a float + if (preg_match("<^0\.\d{6}00 \d+$>", $value)) { + $value = substr($value,11).".".substr($value,2,6); + } else { + throw new \Exception; + } + } + $f = isset(Date::FORMAT[$dateFormat]) ? Date::FORMAT[$dateFormat][0] : $dateFormat; + if ($dateFormat=="iso8601" || $dateFormat=="iso8601m") { + // DateTime::createFromFormat() doesn't provide one catch-all for ISO 8601 timezone specifiers, so we try all of them till one works + if ($dateFormat=="iso8601m") { + $f2 = Date::FORMAT["iso8601"][0]; + $zones = [$f."", $f."\Z", $f."P", $f."O", $f2."", $f2."\Z", $f2."P", $f2."O"]; + } else { + $zones = [$f."", $f."\Z", $f."P", $f."O"]; + } + do { + $ftz = array_shift($zones); + $out = \DateTime::createFromFormat($ftz, $value, new \DateTimeZone("UTC")); + } while (!$out && $zones); + } else { + $out = \DateTime::createFromFormat($f, $value, new \DateTimeZone("UTC")); + } + if (!$out) { + throw new \Exception; + } + return $out; + } else { + return new \DateTime($value, new \DateTimeZone("UTC")); + } + } catch (\Exception $e) { + if ($strict && !$drop) { + throw new ExceptionType("strictFailure", $type); + } + return null; + } + } elseif ($strict && !$drop) { + throw new ExceptionType("strictFailure", $type); + } + return null; + case self::T_ARRAY: + if (is_array($value)) { + return $value; + } elseif ($value instanceof \Traversable) { + $out = []; + foreach ($value as $k => $v) { + $out[$k] = $v; + } + return $out; + } else { + if ($drop) { + return null; + } elseif ($strict) { + // if strict and input is not a string, this is an error + throw new ExceptionType("strictFailure", $type); + } elseif (is_null($value) || (is_float($value) && is_nan($value))) { + return []; + } else { + return [$value]; + } + } + default: + throw new ExceptionType("typeUnknown", $type); // @codeCoverageIgnore + } + } public static function int($value): int { $out = 0; @@ -19,28 +244,42 @@ class ValueInfo { // check if the input is null return self::NULL; } elseif (is_string($value) || (is_object($value) && method_exists($value, "__toString"))) { - $value = (string) $value; + $value = strtolower((string) $value); // normalize a string an integer or float if possible if (!strlen($value)) { // the empty string is equivalent to null when evaluating an integer return self::NULL; - } elseif (filter_var($value, \FILTER_VALIDATE_FLOAT) !== false && !fmod((float) $value, 1)) { + } + // interpret the value as a float + $float = filter_var($value, \FILTER_VALIDATE_FLOAT); + if ($float !== false) { + if (!fmod($float, 1)) { + // an integral float is acceptable + $value = (int) (!strpos($value, "e") ? $value : $float); + } else { + $out += self::FLOAT; + $value = $float; + } + } else { + return $out; + } + } elseif (is_float($value)) { + if (!fmod($value, 1)) { // an integral float is acceptable $value = (int) $value; } else { - return $out; + $out += self::FLOAT; } - } elseif (is_float($value) && !fmod($value, 1)) { - // an integral float is acceptable - $value = (int) $value; } elseif (!is_int($value)) { // if the value is not an integer or integral float, stop return $out; } // mark validity - $out += self::VALID; + if (is_int($value)) { + $out += self::VALID; + } // mark zeroness - if ($value==0) { + if (!$value) { $out += self::ZERO; } // mark negativeness @@ -89,4 +328,16 @@ class ValueInfo { return true; } } + + public static function bool($value, bool $default = null) { + if (is_null($value) || ValueInfo::str($value) & ValueInfo::WHITE) { + return $default; + } + $out = filter_var($value, \FILTER_VALIDATE_BOOLEAN, \FILTER_NULL_ON_FAILURE); + if (is_null($out) && (ValueInfo::int($value) & ValueInfo::VALID)) { + $out = (int) filter_var($value, \FILTER_VALIDATE_FLOAT); + return ($out==1 || $out==0) ? (bool) $out : $default; + } + return !is_null($out) ? $out : $default; + } } diff --git a/locale/en.php b/locale/en.php index 03cf5f6..d232f1f 100644 --- a/locale/en.php +++ b/locale/en.php @@ -70,6 +70,17 @@ return [ 'Exception.JKingWeb/Arsse/Exception.uncoded' => 'The specified exception symbol {0} has no code specified in AbstractException.php', // this should not usually be encountered 'Exception.JKingWeb/Arsse/Exception.unknown' => 'An unknown error has occurred', + 'Exception.JKingWeb/Arsse/ExceptionType.strictFailure' => 'Supplied value could not be normalized to {0, select, + 1 {null} + 2 {boolean} + 3 {integer} + 4 {float} + 5 {datetime} + 6 {string} + 7 {array} + other {requested type} + }', + 'Exception.JKingWeb/Arsse/ExceptionType.typeUnknown' => 'Normalization type {0} is not implemented', 'Exception.JKingWeb/Arsse/Lang/Exception.defaultFileMissing' => 'Default language file "{0}" missing', 'Exception.JKingWeb/Arsse/Lang/Exception.fileMissing' => 'Language file "{0}" is not available', 'Exception.JKingWeb/Arsse/Lang/Exception.fileUnreadable' => 'Insufficient permissions to read language file "{0}"', diff --git a/tests/Misc/TestValueInfo.php b/tests/Misc/TestValueInfo.php index 1a75099..a6f8d97 100644 --- a/tests/Misc/TestValueInfo.php +++ b/tests/Misc/TestValueInfo.php @@ -7,6 +7,10 @@ use JKingWeb\Arsse\Test\Misc\StrClass; /** @covers \JKingWeb\Arsse\Misc\ValueInfo */ class TestValueInfo extends Test\AbstractTest { + public function setUp() { + $this->clearData(); + } + public function testGetIntegerInfo() { $tests = [ [null, I::NULL], @@ -52,9 +56,15 @@ class TestValueInfo extends Test\AbstractTest { ["-000.000", I::VALID | I::ZERO], [false, 0], [true, 0], - [INF, 0], - [-INF, 0], - [NAN, 0], + ["on", 0], + ["off", 0], + ["yes", 0], + ["no", 0], + ["true", 0], + ["false", 0], + [INF, I::FLOAT], + [-INF, I::FLOAT | I::NEG], + [NAN, I::FLOAT], [[], 0], ["some string", 0], [" ", 0], @@ -65,6 +75,10 @@ class TestValueInfo extends Test\AbstractTest { [new StrClass("-1"), I::VALID | I::NEG], [new StrClass("Msg"), 0], [new StrClass(" "), 0], + [2.5, I::FLOAT], + [0.5, I::FLOAT], + ["2.5", I::FLOAT], + ["0.5", I::FLOAT], ]; foreach ($tests as $test) { list($value, $exp) = $test; @@ -116,6 +130,12 @@ class TestValueInfo extends Test\AbstractTest { ["-000.000", I::VALID], [false, 0], [true, 0], + ["on", I::VALID], + ["off", I::VALID], + ["yes", I::VALID], + ["no", I::VALID], + ["true", I::VALID], + ["false", I::VALID], [INF, 0], [-INF, 0], [NAN, 0], @@ -181,6 +201,12 @@ class TestValueInfo extends Test\AbstractTest { ["-000.000", false, true], [false, false, false], [true, false, false], + ["on", false, false], + ["off", false, false], + ["yes", false, false], + ["no", false, false], + ["true", false, false], + ["false", false, false], [INF, false, false], [-INF, false, false], [NAN, false, false], @@ -201,4 +227,290 @@ class TestValueInfo extends Test\AbstractTest { $this->assertSame($expNull, I::id($value, true), "Null test failed for value: ".var_export($value, true)); } } -} + + public function testValidateBoolean() { + $tests = [ + [null, null], + ["", false], + [1, true], + [PHP_INT_MAX, null], + [1.0, true], + ["1.0", true], + ["001.0", true], + ["1.0e2", null], + ["1", true], + ["001", true], + ["1e2", null], + ["+1.0", true], + ["+001.0", true], + ["+1.0e2", null], + ["+1", true], + ["+001", true], + ["+1e2", null], + [0, false], + ["0", false], + ["000", false], + [0.0, false], + ["0.0", false], + ["000.000", false], + ["+0", false], + ["+000", false], + ["+0.0", false], + ["+000.000", false], + [-1, null], + [-1.0, null], + ["-1.0", null], + ["-001.0", null], + ["-1.0e2", null], + ["-1", null], + ["-001", null], + ["-1e2", null], + [-0, false], + ["-0", false], + ["-000", false], + [-0.0, false], + ["-0.0", false], + ["-000.000", false], + [false, false], + [true, true], + ["on", true], + ["off", false], + ["yes", true], + ["no", false], + ["true", true], + ["false", false], + [INF, null], + [-INF, null], + [NAN, null], + [[], null], + ["some string", null], + [" ", null], + [new \StdClass, null], + [new StrClass(""), false], + [new StrClass("1"), true], + [new StrClass("0"), false], + [new StrClass("-1"), null], + [new StrClass("Msg"), null], + [new StrClass(" "), null], + ]; + foreach ($tests as $test) { + list($value, $exp) = $test; + $this->assertSame($exp, I::bool($value), "Null Test failed for value: ".var_export($value, true)); + if (is_null($exp)) { + $this->assertTrue(I::bool($value, true), "True Test failed for value: ".var_export($value, true)); + $this->assertFalse(I::bool($value, false), "False Test failed for value: ".var_export($value, true)); + } + } + } + + public function testNormalizeValues() { + $tests = [ + /* The test data are very dense for this set. Each value is normalized to each of the following types: + + - mixed (no normalization performed) + - null + - boolean + - integer + - float + - string + - array + + For each of these types, there is an expected output value, as well as a boolean indicating whether + the value should pass or fail a strict normalization. Conversion to DateTime is covered below by a different data set + */ + /* Input value null bool int float string array */ + [null, [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], false]], + ["", [null,true], [false,true], [0, false], [0.0, false], ["", true], [[""], false]], + [1, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1], false]], + [PHP_INT_MAX, [null,true], [true, false], [PHP_INT_MAX, true], [(float) PHP_INT_MAX,true], [(string) PHP_INT_MAX, true], [[PHP_INT_MAX], false]], + [1.0, [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[1.0], false]], + ["1.0", [null,true], [true, true], [1, true], [1.0, true], ["1.0", true], [["1.0"], false]], + ["001.0", [null,true], [true, true], [1, true], [1.0, true], ["001.0", true], [["001.0"], false]], + ["1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["1.0e2", true], [["1.0e2"], false]], + ["1", [null,true], [true, true], [1, true], [1.0, true], ["1", true], [["1"], false]], + ["001", [null,true], [true, true], [1, true], [1.0, true], ["001", true], [["001"], false]], + ["1e2", [null,true], [true, false], [100, true], [100.0, true], ["1e2", true], [["1e2"], false]], + ["+1.0", [null,true], [true, true], [1, true], [1.0, true], ["+1.0", true], [["+1.0"], false]], + ["+001.0", [null,true], [true, true], [1, true], [1.0, true], ["+001.0", true], [["+001.0"], false]], + ["+1.0e2", [null,true], [true, false], [100, true], [100.0, true], ["+1.0e2", true], [["+1.0e2"], false]], + ["+1", [null,true], [true, true], [1, true], [1.0, true], ["+1", true], [["+1"], false]], + ["+001", [null,true], [true, true], [1, true], [1.0, true], ["+001", true], [["+001"], false]], + ["+1e2", [null,true], [true, false], [100, true], [100.0, true], ["+1e2", true], [["+1e2"], false]], + [0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0], false]], + ["0", [null,true], [false,true], [0, true], [0.0, true], ["0", true], [["0"], false]], + ["000", [null,true], [false,true], [0, true], [0.0, true], ["000", true], [["000"], false]], + [0.0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[0.0], false]], + ["0.0", [null,true], [false,true], [0, true], [0.0, true], ["0.0", true], [["0.0"], false]], + ["000.000", [null,true], [false,true], [0, true], [0.0, true], ["000.000", true], [["000.000"], false]], + ["+0", [null,true], [false,true], [0, true], [0.0, true], ["+0", true], [["+0"], false]], + ["+000", [null,true], [false,true], [0, true], [0.0, true], ["+000", true], [["+000"], false]], + ["+0.0", [null,true], [false,true], [0, true], [0.0, true], ["+0.0", true], [["+0.0"], false]], + ["+000.000", [null,true], [false,true], [0, true], [0.0, true], ["+000.000", true], [["+000.000"], false]], + [-1, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1], false]], + [-1.0, [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[-1.0], false]], + ["-1.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-1.0", true], [["-1.0"], false]], + ["-001.0", [null,true], [true, false], [-1, true], [-1.0, true], ["-001.0", true], [["-001.0"], false]], + ["-1.0e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1.0e2", true], [["-1.0e2"], false]], + ["-1", [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [["-1"], false]], + ["-001", [null,true], [true, false], [-1, true], [-1.0, true], ["-001", true], [["-001"], false]], + ["-1e2", [null,true], [true, false], [-100, true], [-100.0, true], ["-1e2", true], [["-1e2"], false]], + [-0, [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[-0], false]], + ["-0", [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [["-0"], false]], + ["-000", [null,true], [false,true], [0, true], [-0.0, true], ["-000", true], [["-000"], false]], + [-0.0, [null,true], [false,true], [0, true], [-0.0, true], ["-0", true], [[-0.0], false]], + ["-0.0", [null,true], [false,true], [0, true], [-0.0, true], ["-0.0", true], [["-0.0"], false]], + ["-000.000", [null,true], [false,true], [0, true], [-0.0, true], ["-000.000", true], [["-000.000"], false]], + [false, [null,true], [false,true], [0, false], [0.0, false], ["", false], [[false], false]], + [true, [null,true], [true, true], [1, false], [1.0, false], ["1", false], [[true], false]], + ["on", [null,true], [true, true], [0, false], [0.0, false], ["on", true], [["on"], false]], + ["off", [null,true], [false,true], [0, false], [0.0, false], ["off", true], [["off"], false]], + ["yes", [null,true], [true, true], [0, false], [0.0, false], ["yes", true], [["yes"], false]], + ["no", [null,true], [false,true], [0, false], [0.0, false], ["no", true], [["no"], false]], + ["true", [null,true], [true, true], [0, false], [0.0, false], ["true", true], [["true"], false]], + ["false", [null,true], [false,true], [0, false], [0.0, false], ["false", true], [["false"], false]], + [INF, [null,true], [true, false], [0, false], [INF, true], ["INF", false], [[INF], false]], + [-INF, [null,true], [true, false], [0, false], [-INF, true], ["-INF", false], [[-INF], false]], + [NAN, [null,true], [false,false], [0, false], [NAN, true], ["NAN", false], [[], false]], + [[], [null,true], [false,false], [0, false], [0.0, false], ["", false], [[], true] ], + ["some string", [null,true], [true, false], [0, false], [0.0, false], ["some string", true], [["some string"], false]], + [" ", [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[" "], false]], + [new \StdClass, [null,true], [true, false], [0, false], [0.0, false], ["", false], [[new \StdClass], false]], + [new StrClass(""), [null,true], [false,true], [0, false], [0.0, false], ["", true], [[new StrClass("")], false]], + [new StrClass("1"), [null,true], [true, true], [1, true], [1.0, true], ["1", true], [[new StrClass("1")], false]], + [new StrClass("0"), [null,true], [false,true], [0, true], [0.0, true], ["0", true], [[new StrClass("0")], false]], + [new StrClass("-1"), [null,true], [true, false], [-1, true], [-1.0, true], ["-1", true], [[new StrClass("-1")], false]], + [new StrClass("Msg"), [null,true], [true, false], [0, false], [0.0, false], ["Msg", true], [[new StrClass("Msg")], false]], + [new StrClass(" "), [null,true], [true, false], [0, false], [0.0, false], [" ", true], [[new StrClass(" ")], false]], + [2.5, [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [[2.5], false]], + [0.5, [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [[0.5], false]], + ["2.5", [null,true], [true, false], [2, false], [2.5, true], ["2.5", true], [["2.5"], false]], + ["0.5", [null,true], [true, false], [0, false], [0.5, true], ["0.5", true], [["0.5"], false]], + [$this->d("2010-01-01T00:00:00",0,0), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00",0,0)],false]], + [$this->d("2010-01-01T00:00:00",0,1), [null,true], [true, false], [1262304000, false], [1262304000.0, false], ["2010-01-01T00:00:00Z",true], [[$this->d("2010-01-01T00:00:00",0,1)],false]], + [$this->d("2010-01-01T00:00:00",1,0), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00",1,0)],false]], + [$this->d("2010-01-01T00:00:00",1,1), [null,true], [true, false], [1262322000, false], [1262322000.0, false], ["2010-01-01T05:00:00Z",true], [[$this->d("2010-01-01T00:00:00",1,1)],false]], + [1e14, [null,true], [true, false], [100000000000000,true], [1e14, true], ["100000000000000", true], [[1e14], false]], + [1e-6, [null,true], [true, false], [0, false], [1e-6, true], ["0.000001", true], [[1e-6], false]], + [[1,2,3], [null,true], [true, false], [0, false], [0.0, false], ["", false], [[1,2,3], true] ], + [['a'=>1,'b'=>2], [null,true], [true, false], [0, false], [0.0, false], ["", false], [['a'=>1,'b'=>2], true] ], + [new Test\Result([['a'=>1,'b'=>2]]), [null,true], [true, false], [0, false], [0.0, false], ["", false], [[['a'=>1,'b'=>2]], true] ], + ]; + $params = [ + [I::T_MIXED, "Mixed" ], + [I::T_NULL, "Null", ], + [I::T_BOOL, "Boolean", ], + [I::T_INT, "Integer", ], + [I::T_FLOAT, "Floating point"], + [I::T_STRING, "String", ], + [I::T_ARRAY, "Array", ], + ]; + foreach ($params as $index => $param) { + list($type, $name) = $param; + $this->assertNull(I::normalize(null, $type | I::M_STRICT | I::M_NULL), $name." null-passthrough test failed"); + foreach ($tests as $test) { + list($exp, $pass) = $index ? $test[$index] : [$test[$index], true]; + $value = $test[0]; + $assert = (is_float($exp) && is_nan($exp) ? "assertNan" : (is_scalar($exp) ? "assertSame" : "assertEquals")); + $this->$assert($exp, I::normalize($value, $type), $name." test failed for value: ".var_export($value, true)); + if ($pass) { + $this->$assert($exp, I::normalize($value, $type | I::M_DROP), $name." drop test failed for value: ".var_export($value, true)); + $this->$assert($exp, I::normalize($value, $type | I::M_STRICT), $name." error test failed for value: ".var_export($value, true)); + } else { + $this->assertNull(I::normalize($value, $type | I::M_DROP), $name." drop test failed for value: ".var_export($value, true)); + $exc = new ExceptionType("strictFailure", $type); + try { + $act = I::normalize($value, $type | I::M_STRICT); + } catch (ExceptionType $e) { + $act = $e; + } finally { + $this->assertEquals($exc, $act, $name." error test failed for value: ".var_export($value, true)); + } + } + } + } + // DateTimeInterface tests + $tests = [ + /* Input value microtime iso8601 iso8601m http sql date time unix float '!M j, Y (D)' *strtotime* (null) */ + [null, null, null, null, null, null, null, null, null, null, null, null, ], + [$this->d("2010-01-01T00:00:00",0,0), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), ], + [$this->d("2010-01-01T00:00:00",0,1), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), ], + [$this->d("2010-01-01T00:00:00",1,0), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), ], + [$this->d("2010-01-01T00:00:00",1,1), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), $this->t(1262322000), ], + [1262304000, $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), $this->t(1262304000), ], + [1262304000.123456, $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), $this->t(1262304000.123456), ], + [1262304000.42, $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), $this->t(1262304000.42), ], + ["0.12345600 1262304000", $this->t(1262304000.123456), null, null, null, null, null, null, null, null, null, null, ], + ["0.42 1262304000", null, null, null, null, null, null, null, null, null, null, null, ], + ["2010-01-01T00:00:00", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00Z", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00+0000", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00-0000", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00+00:00", null, $this->t(1262304000), $this->t(1262304000), null, null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01T00:00:00-05:00", null, $this->t(1262322000), $this->t(1262322000), null, null, null, null, null, null, null, $this->t(1262322000), ], + ["2010-01-01T00:00:00.123456Z", null, null, $this->t(1262304000.123456), null, null, null, null, null, null, null, $this->t(1262304000.123456), ], + ["Fri, 01 Jan 2010 00:00:00 GMT", null, null, null, $this->t(1262304000), null, null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01 00:00:00", null, null, null, null, $this->t(1262304000), null, null, null, null, null, $this->t(1262304000), ], + ["2010-01-01", null, null, null, null, null, $this->t(1262304000), null, null, null, null, $this->t(1262304000), ], + ["12:34:56", null, null, null, null, null, null, $this->t(45296), null, null, null, $this->t(strtotime("today")+45296), ], + ["1262304000", null, null, null, null, null, null, null, $this->t(1262304000), null, null, null, ], + ["1262304000.123456", null, null, null, null, null, null, null, null, $this->t(1262304000.123456), null, null, ], + ["1262304000.42", null, null, null, null, null, null, null, null, $this->t(1262304000.42), null, null, ], + ["Jan 1, 2010 (Fri)", null, null, null, null, null, null, null, null, null, $this->t(1262304000), null, ], + ["First day of Jan 2010 12AM", null, null, null, null, null, null, null, null, null, null, $this->t(1262304000), ], + ]; + $formats = [ + "microtime", + "iso8601", + "iso8601m", + "http", + "sql", + "date", + "time", + "unix", + "float", + "!M j, Y (D)", + null, + ]; + $exc = new ExceptionType("strictFailure", I::T_DATE); + foreach ($formats as $index => $format) { + foreach ($tests as $test) { + $value = $test[0]; + $exp = $test[$index+1]; + $this->assertEquals($exp, I::normalize($value, I::T_DATE, $format), "Test failed for format ".var_export($format, true)." using value ".var_export($value, true)); + $this->assertEquals($exp, I::normalize($value, I::T_DATE | I::M_DROP, $format), "Drop test failed for format ".var_export($format, true)." using value ".var_export($value, true)); + // test for exception in case of errors + $exp = $exp ?? $exc; + try { + $act = I::normalize($value, I::T_DATE | I::M_STRICT, $format); + } catch (ExceptionType $e) { + $act = $e; + } finally { + $this->assertEquals($exp, $act, "Error test failed for format ".var_export($format, true)." using value ".var_export($value, true)); + } + } + } + // Array-mode tests + $tests = [ + [I::T_INT | I::M_DROP, new Test\Result([1, 2, 2.2, 3]), [1,2,null,3] ], + [I::T_INT, new Test\Result([1, 2, 2.2, 3]), [1,2,2,3] ], + [I::T_STRING | I::M_STRICT, "Bare string", ["Bare string"]], + ]; + foreach ($tests as $index => $test) { + list($type, $value, $exp) = $test; + $this->assertEquals($exp, I::normalize($value, $type | I::M_ARRAY, "iso8601"), "Failed test #$index"); + } + } + + protected function d($spec, $local, $immutable): \DateTimeInterface { + $tz = $local ? new \DateTimeZone("America/Toronto") : new \DateTimeZone("UTC"); + if ($immutable) { + return \DateTimeImmutable::createFromFormat("!Y-m-d\TH:i:s", $spec, $tz); + } else { + return \DateTime::createFromFormat("!Y-m-d\TH:i:s", $spec, $tz); + } + } + + protected function t(float $spec): \DateTime { + return \DateTime::createFromFormat("U.u", sprintf("%F", $spec), new \DateTimeZone("UTC")); + } +} \ No newline at end of file