diff --git a/bin/run-test-case b/bin/run-test-case index ff2e867b..4575caa6 100755 --- a/bin/run-test-case +++ b/bin/run-test-case @@ -36,24 +36,25 @@ foreach ($argv as $arg) { if (count($arArgs) < 2 || isset($arOptions['--help']) || isset($arOptions['-h'])) { echo << "/" +Run JSON Schema test suite test cases +Usage: run-test-case "[/]" Arguments: test-file Path to a test JSON file. Resolved in order: 1. As given (absolute or relative to CWD) 2. Relative to the bundled test suite root - description "/" — split on the first "/" only. + description "" or "/" — split on the first "/" only. Exact match is tried first, then case-insensitive substring. + Omit the test part to run all tests in the group. Options: -v --verbose Also print the schema -h --help Show this help Exit codes: - 0 Test found and result matches expected - 1 Test found but result does not match expected - 2 Usage error, file not found, or test not found + 0 All matched tests passed + 1 At least one test failed + 2 Usage error, file not found, or group/test not found HLP; exit(0); @@ -148,17 +149,6 @@ if ($testDesc !== null && $foundTest === null) { exit(2); } -// No test description given — list tests in the matched group -if ($foundTest === null) { - echo "Group: {$foundGroup->description}\n"; - echo "Tests:\n"; - foreach ($foundGroup->tests as $test) { - $expect = $test->valid ? 'valid' : 'invalid'; - echo " [{$expect}] {$test->description}\n"; - } - exit(0); -} - // Determine check mode (mirrors JsonSchemaTestSuiteTest::getCheckModeForDraft) $checkMode = in_array($draft, ['draft6', 'draft7'], true) ? Constraint::CHECK_MODE_NORMAL | Constraint::CHECK_MODE_STRICT @@ -192,36 +182,86 @@ if (is_dir($remotesDir)) { $factory = new Factory($schemaStorage); $factory->setDefaultDialect($draftIdentifier->getValue()); -$validator = new Validator($factory); -try { - $validator->validate($foundTest->data, $schema, $checkMode); -} catch (\Exception $e) { - echo "Error during validation: " . $e->getMessage() . "\n"; - exit(2); +/** @param object $test */ +$runTest = static function (object $test) use ($factory, $schema, $checkMode): array { + $validator = new Validator($factory); + try { + $validator->validate($test->data, $schema, $checkMode); + } catch (\Exception $e) { + return ['error' => $e->getMessage()]; + } + $isValid = count($validator->getErrors()) === 0; + return [ + 'passed' => $isValid === $test->valid, + 'isValid' => $isValid, + 'errors' => $validator->getErrors(), + ]; +}; + +$verbose = isset($arOptions['--verbose']) || isset($arOptions['-v']); + +// Run all tests in the group when no specific test was requested +if ($foundTest === null) { + echo "Group: {$foundGroup->description}\n"; + if ($verbose) { + echo "Schema: " . json_encode($schema, JSON_PRETTY_PRINT) . "\n"; + } + $passCount = 0; + $failCount = 0; + foreach ($foundGroup->tests as $test) { + $result = $runTest($test); + if (isset($result['error'])) { + echo sprintf("[ERROR] %s\n", $test->description); + echo " Error during validation: {$result['error']}\n"; + $failCount++; + continue; + } + $expect = $test->valid ? 'valid' : 'invalid'; + $label = $result['passed'] ? 'PASS' : 'FAIL'; + echo sprintf("[%s] [%s] %s\n", $label, $expect, $test->description); + if (!$result['passed']) { + if ($result['isValid']) { + echo " Validator returned valid but the test case expects invalid.\n"; + } else { + foreach ($result['errors'] as $error) { + echo sprintf(" [%s] %s\n", $error['property'], $error['message']); + } + } + $failCount++; + } else { + $passCount++; + } + } + echo "\n{$passCount} passed, {$failCount} failed.\n"; + exit($failCount > 0 ? 1 : 0); } -$isValid = count($validator->getErrors()) === 0; -$passed = $isValid === $foundTest->valid; +$result = $runTest($foundTest); + +if (isset($result['error'])) { + echo "Error during validation: {$result['error']}\n"; + exit(2); +} echo "Group : {$foundGroup->description}\n"; echo "Test : {$foundTest->description}\n"; -if (isset($arOptions['--verbose']) || isset($arOptions['-v'])) { +if ($verbose) { echo "Schema: " . json_encode($schema, JSON_PRETTY_PRINT) . "\n"; } echo "Data : " . json_encode($foundTest->data) . "\n"; echo "Expect: " . ($foundTest->valid ? 'valid' : 'invalid') . "\n"; -echo "Result: " . ($passed ? 'PASS' : 'FAIL') . "\n"; +echo "Result: " . ($result['passed'] ? 'PASS' : 'FAIL') . "\n"; -if (!$passed) { - if ($isValid) { +if (!$result['passed']) { + if ($result['isValid']) { echo "\nValidator returned valid but the test case expects invalid.\n"; } else { echo "\nValidation errors:\n"; - foreach ($validator->getErrors() as $error) { + foreach ($result['errors'] as $error) { echo sprintf(" [%s] %s\n", $error['property'], $error['message']); } } } -exit($passed ? 0 : 1); +exit($result['passed'] ? 0 : 1); diff --git a/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php index 8bcd99f7..30f6c1c8 100644 --- a/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php +++ b/src/JsonSchema/Constraints/Drafts/Draft07/FormatConstraint.php @@ -40,7 +40,11 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n } break; case 'time': - if (!$this->validateDateTime($value, 'H:i:sp') && !$this->validateDateTime($value, 'H:i:s.up')) { + if (!$this->validateDateTime($value, 'H:i:sP') + && !$this->validateDateTime($value, 'H:i:sp') + && !$this->validateDateTime($value, 'H:i:s.up') + && !$this->validateDateTime($value, 'H:i:s.uP') + ) { $this->addError(ConstraintError::FORMAT_TIME(), $path, ['time' => $value, 'format' => $schema->format]); } break; @@ -132,9 +136,14 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n private function validateDateTime(string $datetime, string $format): bool { $datetime = strtoupper($datetime); // Cleanup for lowercase z + $isPhpLt80WithZulu = PHP_VERSION_ID < 80000 && substr($datetime, -1) === 'Z'; $isLeap = substr($datetime, 6, 2) === '60'; $input = $datetime; + // Correct for Zulu in PHP < 8.0 + if ($isPhpLt80WithZulu) { + $input = sprintf('%s+00:00', substr($input, 0, -1)); + } // Correct for leap second if ($isLeap) { $input = sprintf('%s59%s', substr($datetime, 0, 6), substr($datetime, 8)); @@ -153,11 +162,11 @@ private function validateDateTime(string $datetime, string $format): bool $expected = $dt->format($format); // Correct for trailing zeros on microseconds - if ($format === 'H:i:s.up') { + if ($format === 'H:i:s.up' || $format === 'H:i:s.uP') { $expected = sprintf( '%s%s', rtrim($dt->format('H:i:s.u'), '0'), - $dt->format('p') + $dt->format(substr($format, -1)) ); } // Correct back for leap seconds @@ -170,6 +179,10 @@ private function validateDateTime(string $datetime, string $format): bool $expected = sprintf('%s60%s', substr($expected, 0, 6), substr($expected, 8)); } + // Correct back for PHP > 8.0 and Zulu + if ($isPhpLt80WithZulu) { + $expected = sprintf('%sZ', substr($expected, 0, -6)); + } return $datetime === $expected; } diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index c6fc5565..aec88cd6 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -30,8 +30,7 @@ public function testTestCaseValidatesCorrectly( $data, int $checkMode, DraftIdentifiers $draft, - bool $expectedValidationResult, - bool $optional + bool $expectedValidationResult ): void { $schemaStorage = new SchemaStorage(); $id = is_object($schema) && property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI; @@ -41,19 +40,7 @@ public function testTestCaseValidatesCorrectly( $factory->setDefaultDialect($draft->getValue()); $validator = new Validator($factory); - try { - $validator->validate($data, $schema, $checkMode); - } catch (\Exception $e) { - if ($optional) { - $this->markTestSkipped('Optional test case throws exception during validate() invocation: "' . $e->getMessage() . '"'); - } - - throw $e; - } - - if ($optional && $expectedValidationResult !== (count($validator->getErrors()) === 0)) { - $this->markTestSkipped('Optional test case would fail'); - } + $validator->validate($data, $schema, $checkMode); self::assertEquals( $expectedValidationResult, @@ -110,7 +97,6 @@ function ($file) { 'checkMode' => $this->getCheckModeForDraft($baseDraftName), 'draft' => DraftIdentifiers::fromConstraintName($baseDraftName), 'expectedValidationResult' => $test->valid, - 'optional' => str_contains($file->getPathname(), '/optional/') ]; } } @@ -192,6 +178,100 @@ private function shouldNotYieldTest(string $name): bool '[draft7/ref.json]: Location-independent identifier: mismatch is expected to be invalid', '[draft7/refRemote.json]: base URI change - change folder: string is invalid is expected to be invalid', '[draft7/refRemote.json]: Location-independent identifier in remote ref: string is invalid is expected to be invalid', + // Optional: bignum — PHP does not natively support arbitrary-precision integers/floats + '[draft3/optional/bignum.json]: integer: a bignum is an integer is expected to be valid', + '[draft3/optional/bignum.json]: integer: a negative bignum is an integer is expected to be valid', + '[draft3/optional/bignum.json]: float comparison with high precision: comparison works for high numbers is expected to be invalid', + '[draft4/optional/bignum.json]: integer: a bignum is an integer is expected to be valid', + '[draft4/optional/bignum.json]: float comparison with high precision: comparison works for high numbers is expected to be invalid', + '[draft3/optional/bignum.json]: float comparison with high precision on negative numbers: comparison works for very negative numbers is expected to be invalid', + '[draft4/optional/bignum.json]: integer: a negative bignum is an integer is expected to be valid', + '[draft4/optional/bignum.json]: float comparison with high precision: comparison works for high numbers is expected to be invalid', + '[draft4/optional/bignum.json]: float comparison with high precision on negative numbers: comparison works for very negative numbers is expected to be invalid', + '[draft6/optional/bignum.json]: float comparison with high precision: comparison works for high numbers is expected to be invalid', + '[draft6/optional/bignum.json]: float comparison with high precision on negative numbers: comparison works for very negative numbers is expected to be invalid', + '[draft7/optional/bignum.json]: float comparison with high precision: comparison works for high numbers is expected to be invalid', + '[draft7/optional/bignum.json]: float comparison with high precision on negative numbers: comparison works for very negative numbers is expected to be invalid', + // Optional: float-overflow — PHP float precision differs from the ECMAScript model + '[draft4/optional/float-overflow.json]: all integers are multiples of 0.5, if overflow is handled: valid if optional overflow handling is implemented is expected to be valid', + '[draft6/optional/float-overflow.json]: all integers are multiples of 0.5, if overflow is handled: valid if optional overflow handling is implemented is expected to be valid', + '[draft7/optional/float-overflow.json]: all integers are multiples of 0.5, if overflow is handled: valid if optional overflow handling is implemented is expected to be valid', + // Optional: ecmascript-regex — PHP uses PCRE which does not implement ECMAScript regex semantics + '[draft3/optional/ecmascript-regex.json]: ECMA 262 regex dialect recognition: [^] is a valid regex is expected to be valid', + '[draft3/optional/ecmascript-regex.json]: ECMA 262 regex dialect recognition: ECMA 262 has no support for lookbehind is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: ECMA 262 \D matches everything but ascii digits: NKO DIGIT ZERO (as \u escape) matches is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: ECMA 262 \D matches everything but ascii digits: NKO DIGIT ZERO matches (unlike e.g. Python) is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: ECMA 262 \S matches everything but whitespace: zero-width whitespace does not match is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: ECMA 262 \W matches everything but ascii letters: latin-1 e-acute matches (unlike e.g. Python) is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: ECMA 262 \d matches ascii digits only: NKO DIGIT ZERO (as \u escape) does not match is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: ECMA 262 \d matches ascii digits only: NKO DIGIT ZERO does not match (unlike e.g. Python) is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: ECMA 262 \s matches whitespace: zero-width whitespace matches is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: ECMA 262 \w matches ascii letters only: latin-1 e-acute does not match (unlike e.g. Python) is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: \d in pattern matches [0-9], not unicode digits: non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO) is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: \d in patternProperties matches [0-9], not unicode digits: non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO) is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: \w in patternProperties matches [A-Za-z0-9_], not unicode letters: literal unicode character in json string is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: \w in patternProperties matches [A-Za-z0-9_], not unicode letters: unicode character in hex format in string is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: \w in patterns matches [A-Za-z0-9_], not unicode letters: literal unicode character in json string is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: \w in patterns matches [A-Za-z0-9_], not unicode letters: unicode character in hex format in string is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: pattern with non-ASCII digits: ascii digits is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: pattern with non-ASCII digits: ascii non-digits is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: pattern with non-ASCII digits: non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO) is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: patternProperties with non-ASCII digits: ascii digits is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: patternProperties with non-ASCII digits: non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO) is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: patterns always use unicode semantics with pattern: ascii character in json string is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: patterns always use unicode semantics with pattern: literal unicode character in json string is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: patterns always use unicode semantics with pattern: unicode character in hex format in string is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: patterns always use unicode semantics with pattern: unicode matching is case-sensitive is expected to be invalid', + '[draft4/optional/ecmascript-regex.json]: patterns always use unicode semantics with patternProperties: ascii character in json string is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: patterns always use unicode semantics with patternProperties: literal unicode character in json string is expected to be valid', + '[draft4/optional/ecmascript-regex.json]: patterns always use unicode semantics with patternProperties: unicode character in hex format in string is expected to be valid', + '[draft6/optional/ecmascript-regex.json]: ECMA 262 \S matches everything but whitespace: zero-width whitespace does not match is expected to be invalid', + '[draft6/optional/ecmascript-regex.json]: ECMA 262 \s matches whitespace: zero-width whitespace matches is expected to be valid', + '[draft6/optional/ecmascript-regex.json]: \d in patternProperties matches [0-9], not unicode digits: non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO) is expected to be invalid', + '[draft6/optional/ecmascript-regex.json]: \w in patternProperties matches [A-Za-z0-9_], not unicode letters: literal unicode character in json string is expected to be invalid', + '[draft6/optional/ecmascript-regex.json]: \w in patternProperties matches [A-Za-z0-9_], not unicode letters: unicode character in hex format in string is expected to be invalid', + '[draft6/optional/ecmascript-regex.json]: pattern with non-ASCII digits: non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO) is expected to be valid', + '[draft7/optional/ecmascript-regex.json]: ECMA 262 \S matches everything but whitespace: zero-width whitespace does not match is expected to be invalid', + '[draft7/optional/ecmascript-regex.json]: ECMA 262 \s matches whitespace: zero-width whitespace matches is expected to be valid', + '[draft7/optional/ecmascript-regex.json]: \d in patternProperties matches [0-9], not unicode digits: non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO) is expected to be invalid', + '[draft7/optional/ecmascript-regex.json]: \w in patternProperties matches [A-Za-z0-9_], not unicode letters: literal unicode character in json string is expected to be invalid', + '[draft7/optional/ecmascript-regex.json]: \w in patternProperties matches [A-Za-z0-9_], not unicode letters: unicode character in hex format in string is expected to be invalid', + '[draft7/optional/ecmascript-regex.json]: pattern with non-ASCII digits: non-ascii digits (BENGALI DIGIT FOUR, BENGALI DIGIT TWO) is expected to be valid', + // Optional: cross-draft — cross-draft schema resolution ($ref across drafts) is not implemented + '[draft7/optional/cross-draft.json]: refs to future drafts are processed as future drafts: missing bar is invalid is expected to be invalid', + // Optional: idn-email — IDN e-mail format validation is not implemented + '[draft7/optional/format/idn-email.json]: validation of an internationalized e-mail addresses: an invalid e-mail address is expected to be invalid', + '[draft7/optional/format/idn-email.json]: validation of an internationalized e-mail addresses: an invalid idn e-mail address is expected to be invalid', + // Optional: idn-hostname — IDN hostname format validation is not implemented + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: Exceptions that are DISALLOWED, left-to-right chars is expected to be invalid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: Exceptions that are DISALLOWED, right-to-left chars is expected to be invalid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: KATAKANA MIDDLE DOT with no Hiragana, Katakana, or Han is expected to be invalid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: KATAKANA MIDDLE DOT with no other characters is expected to be invalid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: MIDDLE DOT with no following \'l\' is expected to be invalid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: MIDDLE DOT with no preceding \'l\' is expected to be invalid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: MIDDLE DOT with nothing following is expected to be invalid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: MIDDLE DOT with nothing preceding is expected to be invalid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: ZERO WIDTH JOINER preceded by Virama is expected to be valid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: ZERO WIDTH NON-JOINER not preceded by Virama but matches regexp is expected to be valid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: ZERO WIDTH NON-JOINER preceded by Virama is expected to be valid', + '[draft7/optional/format/idn-hostname.json]: validation of internationalized host names: contains illegal char U+302E Hangul single dot tone mark is expected to be invalid', + // Optional: iri / iri-reference — IRI format validation is not implemented + '[draft7/optional/format/iri-reference.json]: validation of IRI References: an invalid IRI Reference is expected to be invalid', + '[draft7/optional/format/iri-reference.json]: validation of IRI References: an invalid IRI fragment is expected to be invalid', + '[draft7/optional/format/iri.json]: validation of IRIs: an invalid IRI based on IPv6 is expected to be invalid', + '[draft7/optional/format/iri.json]: validation of IRIs: an invalid IRI is expected to be invalid', + '[draft7/optional/format/iri.json]: validation of IRIs: an invalid IRI though valid IRI reference is expected to be invalid', + '[draft7/optional/format/iri.json]: validation of IRIs: an invalid relative IRI Reference is expected to be invalid', + // Optional: regex format — regex format validation does not check for valid ECMA-262 regex syntax + '[draft7/optional/format/regex.json]: validation of regular expressions: a regular expression with unclosed parens is invalid is expected to be invalid', + // Optional: relative-json-pointer — relative JSON pointer format validation is not implemented + '[draft7/optional/format/relative-json-pointer.json]: validation of Relative JSON Pointers (RJP): ## is not a valid json-pointer is expected to be invalid', + '[draft7/optional/format/relative-json-pointer.json]: validation of Relative JSON Pointers (RJP): an invalid RJP that is a valid JSON Pointer is expected to be invalid', + '[draft7/optional/format/relative-json-pointer.json]: validation of Relative JSON Pointers (RJP): empty string is expected to be invalid', + '[draft7/optional/format/relative-json-pointer.json]: validation of Relative JSON Pointers (RJP): explicit positive prefix is expected to be invalid', + '[draft7/optional/format/relative-json-pointer.json]: validation of Relative JSON Pointers (RJP): negative prefix is expected to be invalid', + '[draft7/optional/format/relative-json-pointer.json]: validation of Relative JSON Pointers (RJP): zero cannot be followed by other digits, plus json-pointer is expected to be invalid', + '[draft7/optional/format/relative-json-pointer.json]: validation of Relative JSON Pointers (RJP): zero cannot be followed by other digits, plus octothorpe is expected to be invalid', ]; if ($this->is32Bit()) {