diff options
author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2023-12-20 00:47:15 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-12-20 05:47:15 +0000 |
commit | 8b943cc97fab890085448122e7c13dd035d0e238 (patch) | |
tree | a7a749a44771c6a82b1b72bb35cc0c81d57ddb54 /test/json.test.js | |
parent | b13fbd47941fc20cf623871396e34a6dfe9b4dba (diff) |
JSON validation (#394)
* Set up JSON testing
* Add schema validation
* Use parseJson
* Finish types
* Disambiguate ext/json-schema from node dependency with the same name
* Add support for specifying the jsconfig file
* Don't expose types
* Update types
* Use dictionary map type
* Fix types
* Fix AJV warnings
* Move types
* Move anb rename file
* Move common mocks
* Simplify types
Diffstat (limited to 'test/json.test.js')
-rw-r--r-- | test/json.test.js | 189 |
1 files changed, 189 insertions, 0 deletions
diff --git a/test/json.test.js b/test/json.test.js new file mode 100644 index 00000000..8cf01491 --- /dev/null +++ b/test/json.test.js @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2023 Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +import Ajv from 'ajv'; +import {readFileSync} from 'fs'; +import {join, dirname as pathDirname} from 'path'; +import {createGenerator} from 'ts-json-schema-generator'; +import {fileURLToPath} from 'url'; +import {describe, expect, test} from 'vitest'; +import {parseJson} from '../dev/json.js'; +import {getAllFiles} from '../dev/util.js'; + +const dirname = pathDirname(fileURLToPath(import.meta.url)); +const rootDir = join(dirname, '..'); + +/** + * @param {import('test/json').JsconfigType|undefined} jsconfigType + * @returns {string} + */ +function getJsconfigPath(jsconfigType) { + let path; + switch (jsconfigType) { + case 'dev': path = '../dev/jsconfig.json'; break; + case 'test': path = '../test/jsconfig.json'; break; + default: path = '../jsconfig.json'; break; + } + return join(dirname, path); +} + +/** + * @returns {Ajv} + */ +function createAjv() { + return new Ajv({ + meta: true, + strictTuples: false, + allowUnionTypes: true + }); +} + +/** + * @param {string} path + * @param {string} type + * @param {import('test/json').JsconfigType|undefined} jsconfigType + * @returns {import('ajv').ValidateFunction<unknown>} + */ +function createValidatorFunctionFromTypeScript(path, type, jsconfigType) { + /** @type {import('ts-json-schema-generator/dist/src/Config').Config} */ + const config = { + path, + tsconfig: getJsconfigPath(jsconfigType), + type, + jsDoc: 'none', + additionalProperties: false, + minify: false, + expose: 'none', + strictTuples: true + }; + const schema = createGenerator(config).createSchema(config.type); + const ajv = createAjv(); + return ajv.compile(schema); +} + +/** + * @param {string} path + * @returns {import('ajv').ValidateFunction<unknown>} + */ +function createValidatorFunctionFromSchemaJson(path) { + /** @type {import('ajv').Schema} */ + const schema = parseJson(readFileSync(path, {encoding: 'utf8'})); + const ajv = createAjv(); + return ajv.compile(schema); +} + +/** + * @param {string} value + * @returns {string} + */ +function normalizePathDirectorySeparators(value) { + return value.replace(/\\/g, '/'); +} + + +describe.concurrent('JSON validation', () => { + const ignoreDirectories = new Set([ + 'builds', + 'dictionaries', + 'node_modules', + 'playwright-report', + 'playwright', + 'test-results', + 'dev/lib', + 'test/playwright' + ]); + + const existingJsonFiles = getAllFiles(rootDir, (path, isDirectory) => { + const fileNameNormalized = normalizePathDirectorySeparators(path); + if (isDirectory) { + return !ignoreDirectories.has(fileNameNormalized); + } else { + return /\.json$/i.test(fileNameNormalized); + } + }); + /** @type {Set<string>} */ + const existingJsonFileSet = new Set(); + for (const path of existingJsonFiles) { + existingJsonFileSet.add(normalizePathDirectorySeparators(path)); + } + + const jsonFileName = 'json.json'; + + /** @type {import('test/json').JsonInfo} */ + const jsonFileData = parseJson(readFileSync(join(dirname, `data/${jsonFileName}`), {encoding: 'utf8'})); + + test(`Each item in ${jsonFileName} must have a unique path`, () => { + /** @type {Set<string>} */ + const set = new Set(); + for (const {path} of jsonFileData.files) { + set.add(path); + } + expect(set.size).toBe(jsonFileData.files.length); + }); + + /** @type {Map<string, import('test/json').JsonFileInfo>} */ + const jsonFileMap = new Map(); + for (const item of jsonFileData.files) { + jsonFileMap.set(item.path, item); + } + + // Validate file existance + const requiredFiles = jsonFileData.files.filter((v) => !v.ignore); + test.each(requiredFiles)('File must exist in project: $path', ({path}) => { + expect(existingJsonFileSet.has(path)).toBe(true); + }); + + // Validate new files + const existingJsonFiles2 = existingJsonFiles.map((path) => ({path: normalizePathDirectorySeparators(path)})); + test.each(existingJsonFiles2)(`File must exist in ${jsonFileName}: $path`, ({path}) => { + expect(jsonFileMap.has(path)).toBe(true); + }); + + // Validate schemas 1 + /** @type {import('test/json').JsonFileParseInfo[]} */ + const schemaValidationTargets1 = []; + for (const info of jsonFileData.files) { + if (info.ignore || !existingJsonFileSet.has(info.path)) { continue; } + schemaValidationTargets1.push(info); + } + test.each(schemaValidationTargets1)('Validating file against TypeScript: $path', ({path, typeFile, type, jsconfig}) => { + const validate = createValidatorFunctionFromTypeScript(join(rootDir, typeFile), type, jsconfig); + const data = parseJson(readFileSync(join(rootDir, path), {encoding: 'utf8'})); + const valid = validate(data); + const {errors} = validate; + expect(errors).toBe(null); + expect(valid).toBe(true); + }); + + // Validate schemas 2 + /** @type {{path: string, schema: string}[]} */ + const schemaValidationTargets2 = []; + for (const info of jsonFileData.files) { + if (info.ignore || !existingJsonFileSet.has(info.path)) { continue; } + const {schema, path} = info; + if (typeof schema !== 'string') { continue; } + schemaValidationTargets2.push({schema, path}); + } + test.each(schemaValidationTargets2)('Validating file against schema: $path', ({path, schema}) => { + const validate = createValidatorFunctionFromSchemaJson(join(rootDir, schema)); + const data = parseJson(readFileSync(join(rootDir, path), {encoding: 'utf8'})); + const valid = validate(data); + const {errors} = validate; + expect(errors).toBe(null); + expect(valid).toBe(true); + }); +}); |