diff --git a/package.json b/package.json index 4d9d1ba..02b77b4 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "test:metadata": "npm run compile && node ./test/runMetadataHeaderTests.js", "test:formatter": "npm run compile && node ./test/runFormatterTests.js", "test:corpus": "npm run compile && node ./test/runFormatterCorpusTests.js", + "test:corpus:helpers": "npm run compile && node ./test/runFormatterCorpusHelperTests.js", "test:project": "npm run compile && node ./test/runProjectValidationTests.js", "test:diagnostics": "npm run compile && node ./test/runDiagnosticsTests.js", "validate": "npm run check && npm test", diff --git a/test/helpers/formatterCorpus.js b/test/helpers/formatterCorpus.js new file mode 100644 index 0000000..b19ca70 --- /dev/null +++ b/test/helpers/formatterCorpus.js @@ -0,0 +1,129 @@ +const fs = require('fs'); +const path = require('path'); + +const { assert } = require('./runTest'); +const { formatSql, watcomDialect, mssqlDialect, defaultOptions } = require('../formatter/helpers'); + +const corpusRoot = path.join(__dirname, '..', 'corpus'); +const dialectsByCorpusDirectory = new Map([ + ['watcom', watcomDialect], + ['mssql', mssqlDialect], +]); + +function listFiles(directory, predicate) { + if (!fs.existsSync(directory)) { + return []; + } + + return fs + .readdirSync(directory, { withFileTypes: true }) + .flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + return listFiles(entryPath, predicate); + } + + return entry.isFile() && predicate(entry.name) ? [entryPath] : []; + }) + .sort((left, right) => left.localeCompare(right)); +} + +function listInputFixtures(directory) { + return listFiles(directory, (fileName) => fileName.endsWith('.input.sql')); +} + +function listSqlFixtureFiles(directory) { + return listFiles(directory, (fileName) => fileName.endsWith('.sql')); +} + +function getExpectedFixturePath(inputPath) { + return inputPath.replace(/\.input\.sql$/u, '.expected.sql'); +} + +function getInputFixturePath(expectedPath) { + return expectedPath.replace(/\.expected\.sql$/u, '.input.sql'); +} + +function getDialectForFixture(inputPath, root = corpusRoot) { + const relativePath = path.relative(root, inputPath); + const corpusDirectory = relativePath.split(path.sep)[0]; + const dialect = dialectsByCorpusDirectory.get(corpusDirectory); + + if (!dialect) { + throw new Error(`No corpus dialect is configured for '${corpusDirectory}'.`); + } + + return dialect; +} + +function getFixtureName(inputPath, root = corpusRoot) { + return path.relative(root, inputPath).replace(/\.input\.sql$/u, ''); +} + +function getRelativeFixturePath(fixturePath) { + return path.relative(process.cwd(), fixturePath); +} + +function toCrLf(text) { + return text.replace(/\r\n|\r|\n/gu, '\n').replace(/\n/gu, '\r\n'); +} + +function readFixture(fixturePath) { + return fs.readFileSync(fixturePath, 'utf8'); +} + +function findCorpusFixturePairingIssues(directory) { + return listSqlFixtureFiles(directory).flatMap((fixturePath) => { + const relativeFixturePath = getRelativeFixturePath(fixturePath); + + if (fixturePath.endsWith('.input.sql')) { + const expectedPath = getExpectedFixturePath(fixturePath); + + return fs.existsSync(expectedPath) + ? [] + : [`Missing expected corpus fixture for ${relativeFixturePath}.`]; + } + + if (fixturePath.endsWith('.expected.sql')) { + const inputPath = getInputFixturePath(fixturePath); + + return fs.existsSync(inputPath) + ? [] + : [`Missing input corpus fixture for ${relativeFixturePath}.`]; + } + + return [ + `Unsupported corpus SQL fixture name ${relativeFixturePath}; use .input.sql or .expected.sql.`, + ]; + }); +} + +function listFixturePairs(root = corpusRoot) { + return listInputFixtures(root).map((inputPath) => ({ + dialect: getDialectForFixture(inputPath, root), + expectedPath: getExpectedFixturePath(inputPath), + inputPath, + name: getFixtureName(inputPath, root), + })); +} + +function assertCorpusFormatting({ dialect, expected, fixtureName, input, variant }) { + const result = formatSql(input, dialect, defaultOptions); + const idempotentResult = formatSql(expected, dialect, defaultOptions); + const testName = variant ? `${fixtureName} (${variant})` : fixtureName; + + assert.equal(result.text, expected, `${testName} did not format to the expected output.`); + assert.equal(idempotentResult.text, expected, `${testName} expected output is not idempotent.`); +} + +module.exports = { + assertCorpusFormatting, + corpusRoot, + findCorpusFixturePairingIssues, + getExpectedFixturePath, + getInputFixturePath, + listFixturePairs, + readFixture, + toCrLf, +}; diff --git a/test/runAllTests.js b/test/runAllTests.js index 5a41de2..d89e6db 100644 --- a/test/runAllTests.js +++ b/test/runAllTests.js @@ -5,6 +5,7 @@ const suites = [ './runMetadataHeaderTests', './runMetadataRegressionTests', './runFormatterTests', + './runFormatterCorpusHelperTests', './runFormatterCorpusTests', './runDiagnosticsTests', ]; diff --git a/test/runFormatterCorpusHelperTests.js b/test/runFormatterCorpusHelperTests.js new file mode 100644 index 0000000..bb61e54 --- /dev/null +++ b/test/runFormatterCorpusHelperTests.js @@ -0,0 +1,75 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { assert, runTest } = require('./helpers/runTest'); +const { findCorpusFixturePairingIssues, toCrLf } = require('./helpers/formatterCorpus'); + +function withTemporaryCorpus(testFn) { + const corpusDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'sqlovely-corpus-')); + + try { + testFn(corpusDirectory); + } finally { + fs.rmSync(corpusDirectory, { recursive: true, force: true }); + } +} + +function writeFixture(root, relativePath, contents = 'select 1;\n') { + const fixturePath = path.join(root, relativePath); + + fs.mkdirSync(path.dirname(fixturePath), { recursive: true }); + fs.writeFileSync(fixturePath, contents, 'utf8'); +} + +runTest('formatter corpus helpers: accept paired fixtures', () => { + withTemporaryCorpus((corpusDirectory) => { + writeFixture(corpusDirectory, 'watcom/example.input.sql'); + writeFixture(corpusDirectory, 'watcom/example.expected.sql'); + + assert.deepEqual(findCorpusFixturePairingIssues(corpusDirectory), []); + }); +}); + +runTest('formatter corpus helpers: detect missing expected fixtures', () => { + withTemporaryCorpus((corpusDirectory) => { + writeFixture(corpusDirectory, 'watcom/missing-expected.input.sql'); + + assert.deepEqual(findCorpusFixturePairingIssues(corpusDirectory), [ + `Missing expected corpus fixture for ${path.relative( + process.cwd(), + path.join(corpusDirectory, 'watcom/missing-expected.input.sql'), + )}.`, + ]); + }); +}); + +runTest('formatter corpus helpers: detect missing input fixtures', () => { + withTemporaryCorpus((corpusDirectory) => { + writeFixture(corpusDirectory, 'watcom/missing-input.expected.sql'); + + assert.deepEqual(findCorpusFixturePairingIssues(corpusDirectory), [ + `Missing input corpus fixture for ${path.relative( + process.cwd(), + path.join(corpusDirectory, 'watcom/missing-input.expected.sql'), + )}.`, + ]); + }); +}); + +runTest('formatter corpus helpers: reject unsupported SQL fixture names', () => { + withTemporaryCorpus((corpusDirectory) => { + writeFixture(corpusDirectory, 'watcom/unsupported.sql'); + + assert.deepEqual(findCorpusFixturePairingIssues(corpusDirectory), [ + `Unsupported corpus SQL fixture name ${path.relative( + process.cwd(), + path.join(corpusDirectory, 'watcom/unsupported.sql'), + )}; use .input.sql or .expected.sql.`, + ]); + }); +}); + +runTest('formatter corpus helpers: normalize mixed line endings to CRLF', () => { + assert.equal(toCrLf('first\nsecond\rthird\r\nfourth'), 'first\r\nsecond\r\nthird\r\nfourth'); +}); diff --git a/test/runFormatterCorpusTests.js b/test/runFormatterCorpusTests.js index 6b75ee7..5c05371 100644 --- a/test/runFormatterCorpusTests.js +++ b/test/runFormatterCorpusTests.js @@ -1,121 +1,12 @@ -const fs = require('fs'); -const path = require('path'); - const { assert, runTest } = require('./helpers/runTest'); -const { formatSql, watcomDialect, mssqlDialect, defaultOptions } = require('./formatter/helpers'); - -const corpusRoot = path.join(__dirname, 'corpus'); -const dialectsByCorpusDirectory = new Map([ - ['watcom', watcomDialect], - ['mssql', mssqlDialect], -]); - -function listFiles(directory, predicate) { - if (!fs.existsSync(directory)) { - return []; - } - - return fs - .readdirSync(directory, { withFileTypes: true }) - .flatMap((entry) => { - const entryPath = path.join(directory, entry.name); - - if (entry.isDirectory()) { - return listFiles(entryPath, predicate); - } - - return entry.isFile() && predicate(entry.name) ? [entryPath] : []; - }) - .sort((left, right) => left.localeCompare(right)); -} - -function listInputFixtures(directory) { - return listFiles(directory, (fileName) => fileName.endsWith('.input.sql')); -} - -function listSqlFixtureFiles(directory) { - return listFiles(directory, (fileName) => fileName.endsWith('.sql')); -} - -function getExpectedFixturePath(inputPath) { - return inputPath.replace(/\.input\.sql$/u, '.expected.sql'); -} - -function getInputFixturePath(expectedPath) { - return expectedPath.replace(/\.expected\.sql$/u, '.input.sql'); -} - -function getDialectForFixture(inputPath) { - const relativePath = path.relative(corpusRoot, inputPath); - const corpusDirectory = relativePath.split(path.sep)[0]; - const dialect = dialectsByCorpusDirectory.get(corpusDirectory); - - if (!dialect) { - throw new Error(`No corpus dialect is configured for '${corpusDirectory}'.`); - } - - return dialect; -} - -function getFixtureName(inputPath) { - return path.relative(corpusRoot, inputPath).replace(/\.input\.sql$/u, ''); -} - -function getRelativeFixturePath(fixturePath) { - return path.relative(process.cwd(), fixturePath); -} - -function toCrLf(text) { - return text.replace(/\r\n|\r|\n/gu, '\n').replace(/\n/gu, '\r\n'); -} - -function readFixture(fixturePath) { - return fs.readFileSync(fixturePath, 'utf8'); -} - -function findCorpusFixturePairingIssues(directory) { - return listSqlFixtureFiles(directory).flatMap((fixturePath) => { - const relativeFixturePath = getRelativeFixturePath(fixturePath); - - if (fixturePath.endsWith('.input.sql')) { - const expectedPath = getExpectedFixturePath(fixturePath); - - return fs.existsSync(expectedPath) - ? [] - : [`Missing expected corpus fixture for ${relativeFixturePath}.`]; - } - - if (fixturePath.endsWith('.expected.sql')) { - const inputPath = getInputFixturePath(fixturePath); - - return fs.existsSync(inputPath) - ? [] - : [`Missing input corpus fixture for ${relativeFixturePath}.`]; - } - - return [ - `Unsupported corpus SQL fixture name ${relativeFixturePath}; use .input.sql or .expected.sql.`, - ]; - }); -} - -function listFixturePairs() { - return listInputFixtures(corpusRoot).map((inputPath) => ({ - dialect: getDialectForFixture(inputPath), - expectedPath: getExpectedFixturePath(inputPath), - inputPath, - name: getFixtureName(inputPath), - })); -} - -function assertCorpusFormatting({ dialect, expected, fixtureName, input, variant }) { - const result = formatSql(input, dialect, defaultOptions); - const idempotentResult = formatSql(expected, dialect, defaultOptions); - const testName = variant ? `${fixtureName} (${variant})` : fixtureName; - - assert.equal(result.text, expected, `${testName} did not format to the expected output.`); - assert.equal(idempotentResult.text, expected, `${testName} expected output is not idempotent.`); -} +const { + assertCorpusFormatting, + corpusRoot, + findCorpusFixturePairingIssues, + listFixturePairs, + readFixture, + toCrLf, +} = require('./helpers/formatterCorpus'); const fixturePairs = listFixturePairs();