From 1271a0c8c0d7cb076bd45fa1c4290f0cd031a953 Mon Sep 17 00:00:00 2001 From: "J. King" Date: Thu, 19 Oct 2017 15:18:58 -0400 Subject: [PATCH] Add ValueInfo::normalize method This method provides generalized, consistent type casting more versatile than PHP's basic type juggling while hiding the significant complexity in achieving this. While this commit does not change any existing code to use the new method, the intent is for both API handlers and database drivers to use the same basic rules for type conversion while still allowing for differing failure modes. --- lib/AbstractException.php | 2 + lib/ExceptionType.php | 6 + lib/Misc/Date.php | 16 +- lib/Misc/ValueInfo.php | 269 ++++++++++++++++++++++++++++- locale/en.php | 11 ++ tests/Misc/TestValueInfo.php | 320 ++++++++++++++++++++++++++++++++++- 6 files changed, 609 insertions(+), 15 deletions(-) create mode 100644 lib/ExceptionType.php diff --git a/lib/AbstractException.php b/lib/AbstractException.php index 4d630c74..5d5ae3d3 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 00000000..16094cef --- /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 040243dc..09dca6d2 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)) { - // an integral float is acceptable - $value = (int) $value; + } + // 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) && !fmod($value, 1)) { - // an integral float is acceptable - $value = (int) $value; + } elseif (is_float($value)) { + if (!fmod($value, 1)) { + // an integral float is acceptable + $value = (int) $value; + } else { + $out += self::FLOAT; + } } 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 03cf5f6c..d232f1f8 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 1a75099d..a6f8d970 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