diff --git a/bids-validator/src/schema/context.test.ts b/bids-validator/src/schema/context.test.ts index c91d5bfed..958d6f9ca 100644 --- a/bids-validator/src/schema/context.test.ts +++ b/bids-validator/src/schema/context.test.ts @@ -1,113 +1,26 @@ import { assert } from '../deps/asserts.ts' import { DatasetIssues } from '../issues/datasetIssues.ts' -import { FileTree } from '../types/filetree.ts' import { BIDSContext } from './context.ts' -import { nullReadBytes } from '../tests/nullReadBytes.ts' - -const issues = new DatasetIssues() - -const text = () => Promise.resolve('') - -const anatJson = () => - Promise.resolve( - JSON.stringify({ - rootOverwrite: 'anat', - subOverwrite: 'anat', - anatValue: 'anat', - }), - ) -const subjectJson = () => - Promise.resolve( - JSON.stringify({ subOverwrite: 'subject', subValue: 'subject' }), - ) -const rootJson = () => - Promise.resolve(JSON.stringify({ rootOverwrite: 'root', rootValue: 'root' })) - -const rootFileTree = new FileTree('/', '') -const subjectFileTree = new FileTree('/sub-01', 'sub-01', rootFileTree) -const sessionFileTree = new FileTree( - '/sub-01/ses-01', - 'ses-01', - subjectFileTree, -) -const anatFileTree = new FileTree( - '/sub-01/ses-01/anat', - 'anat', - sessionFileTree, -) - -const dataFile = { - text, - path: '/sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz', - name: 'sub-01_ses-01_T1w.nii.gz', - size: 311112, - ignored: false, - stream: new ReadableStream(), - readBytes: nullReadBytes, -} - -anatFileTree.files = [ - dataFile, - { - text: anatJson, - path: '/sub-01/ses-01/anat/sub-01_ses-01_T1w.json', - name: 'sub-01_ses-01_T1w.json', - size: 311112, - ignored: false, - stream: new ReadableStream(), - readBytes: nullReadBytes, - }, -] - -sessionFileTree.files = [] -sessionFileTree.directories = [anatFileTree] - -subjectFileTree.files = [ - { - text: subjectJson, - path: '/sub-01/ses-01_T1w.json', - name: 'ses-01_T1w.json', - size: 311112, - ignored: false, - stream: new ReadableStream(), - readBytes: nullReadBytes, - }, -] -subjectFileTree.directories = [sessionFileTree] - -rootFileTree.files = [ - { - text: rootJson, - path: '/T1w.json', - name: 'T1w.json', - size: 311112, - ignored: false, - stream: new ReadableStream(), - readBytes: nullReadBytes, - }, -] -rootFileTree.directories = [subjectFileTree] - -let context = new BIDSContext(anatFileTree, dataFile, issues) +import { dataFile, rootFileTree } from './fixtures.test.ts' Deno.test('test context LoadSidecar', async (t) => { + const context = new BIDSContext(rootFileTree, dataFile, new DatasetIssues()) await context.loadSidecar(rootFileTree) await t.step('sidecar overwrites correct fields', () => { const { rootOverwrite, subOverwrite } = context.sidecar - assert(rootOverwrite, 'anat') - assert(subOverwrite, 'anat') + assert(rootOverwrite === 'anat') + assert(subOverwrite === 'anat') }) await t.step('sidecar adds new fields at each level', () => { const { rootValue, subValue, anatValue } = context.sidecar - assert(rootValue, 'root') - assert(subValue, 'subject') - assert(anatValue, 'anat') + assert(rootValue === 'root') + assert(subValue === 'subject') + assert(anatValue === 'anat') }) }) -context = new BIDSContext(rootFileTree, dataFile, issues) - Deno.test('test context loadSubjects', async (t) => { + const context = new BIDSContext(rootFileTree, dataFile, new DatasetIssues()) await context.loadSubjects() await t.step('context produces correct subjects object', () => { assert(context.dataset.subjects, 'subjects object exists') diff --git a/bids-validator/src/schema/expressionLanguage.test.ts b/bids-validator/src/schema/expressionLanguage.test.ts new file mode 100644 index 000000000..0ca4b74c0 --- /dev/null +++ b/bids-validator/src/schema/expressionLanguage.test.ts @@ -0,0 +1,206 @@ +import { assert } from '../deps/asserts.ts' +import { expressionFunctions } from './expressionLanguage.ts' +import { dataFile, rootFileTree } from './fixtures.test.ts' +import { BIDSContext } from './context.ts' +import { DatasetIssues } from '../issues/datasetIssues.ts' + +Deno.test('test expression functions', async (t) => { + const context = new BIDSContext(rootFileTree, dataFile, new DatasetIssues()) + + await t.step('index function', () => { + const index = expressionFunctions.index + assert(index([1, 2, 3], 2) === 1) + assert(index([1, 2, 3], 4) === null) + assert(index(['a', 'b', 'c'], 'b') === 1) + assert(index(['a', 'b', 'c'], 'd') === null) + }) + await t.step('intersects function', () => { + const intersects = expressionFunctions.intersects + assert(intersects([1, 2, 3], [2, 3, 4]) === true) + assert(intersects([1, 2, 3], [4, 5, 6]) === false) + assert(intersects(['abc', 'def'], ['def']) === true) + assert(intersects(['abc', 'def'], ['ghi']) === false) + + // Promote scalars to arrays + // @ts-ignore + assert(intersects('abc', ['abc', 'def']) === true) + // @ts-ignore + assert(intersects('abc', ['a', 'b', 'c']) === false) + }) + await t.step('match function', () => { + const match = expressionFunctions.match + assert(match('abc', 'abc') === true) + assert(match('abc', 'def') === false) + assert(match('abc', 'a.*') === true) + assert(match('abc', 'd.*') === false) + }) + await t.step('type function', () => { + const type = expressionFunctions.type + assert(type(1) === 'number') + assert(type('abc') === 'string') + assert(type([1, 2, 3]) === 'array') + assert(type({ a: 1, b: 2 }) === 'object') + assert(type(true) === 'boolean') + assert(type(null) === 'null') + assert(type(undefined) === 'null') + }) + await t.step('min function', () => { + const min = expressionFunctions.min + assert(min([1, 2, 3]) === 1) + assert(min([3, 2, 1]) === 1) + assert(min([]) === Infinity) + // @ts-ignore + assert(min(null) === null) + }) + await t.step('max function', () => { + const max = expressionFunctions.max + assert(max([1, 2, 3]) === 3) + assert(max([3, 2, 1]) === 3) + assert(max([]) === -Infinity) + // @ts-ignore + assert(max(null) === null) + }) + await t.step('length function', () => { + const length = expressionFunctions.length + assert(length([1, 2, 3]) === 3) + // Out-of-scope (but permitted) inputs + // @ts-ignore + assert(length('abc') === 3) + // Out-of-scope inputs + // @ts-ignore + assert(length({ a: 1, b: 2 }) === null) + // @ts-ignore + assert(length(true) === null) + // @ts-ignore + assert(length(null) === null) + }) + await t.step('count function', () => { + const count = expressionFunctions.count + assert(count(['a', 'b', 'a', 'b'], 'a') === 2) + assert(count(['a', 'b', 'a', 'b'], 'c') === 0) + }) + await t.step('exists(..., "dataset") function', () => { + const exists = expressionFunctions.exists.bind(context) + assert(exists([], 'dataset') === 0) + assert( + exists(['sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz'], 'dataset') === 1, + ) + assert( + exists( + ['sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz', 'T1w.json'], + 'dataset', + ) === 2, + ) + }) + await t.step('exists(..., "subject") function', () => { + const exists = expressionFunctions.exists.bind(context) + assert(exists([], 'subject') === 0) + assert(exists(['ses-01/anat/sub-01_ses-01_T1w.nii.gz'], 'subject') === 1) + assert( + exists( + ['ses-01/anat/sub-01_ses-01_T1w.nii.gz', 'T1w.json'], + 'subject', + ) === 1, + ) + }) + await t.step('exists(..., "stimuli") function', () => { + const exists = expressionFunctions.exists.bind(context) + assert(exists([], 'stimuli') === 0) + assert(exists(['stimfile1.png'], 'stimuli') === 1) + assert(exists(['stimfile1.png', 'stimfile2.png'], 'stimuli') === 2) + assert(exists(['X.png', 'Y.png'], 'stimuli') === 0) + }) + await t.step('exists(..., "bids-uri") function', () => { + const exists = expressionFunctions.exists.bind(context) + assert(exists([], 'subject') === 0) + assert( + exists( + ['bids::sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz'], + 'bids-uri', + ) === 1, + ) + assert( + exists( + ['bids::sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz', 'bids::T1w.json'], + 'bids-uri', + ) === 2, + ) + // Not yet implemented; currently returns length of array + // assert(exists(['bids::sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz', 'bids::T2w.json'], 'bids-uri') === 1) + }) + await t.step('substr function', () => { + const substr = expressionFunctions.substr + assert(substr('abc', 0, 1) === 'a') + assert(substr('abc', 1, 2) === 'b') + assert(substr('abc', 2, 3) === 'c') + assert(substr('abc', 3, 4) === '') + assert(substr('abc', 0, 4) === 'abc') + // @ts-ignore + assert(substr(null, 0, 1) === null) + // @ts-ignore + assert(substr('abc', null, 1) === null) + // @ts-ignore + assert(substr('abc', 0, null) === null) + }) + await t.step('sorted(..., "numeric") function', () => { + const sorted = expressionFunctions.sorted + const array_equal = (a: any[], b: any[]) => + a.length === b.length && a.every((v, i) => v === b[i]) + assert(array_equal(sorted([3, 2, 1], 'numeric'), [1, 2, 3])) + assert(array_equal(sorted([1, 2, 3], 'numeric'), [1, 2, 3])) + assert(array_equal(sorted(['3', '2', '1'], 'numeric'), ['1', '2', '3'])) + assert(array_equal(sorted(['1', '2', '3'], 'numeric'), ['1', '2', '3'])) + assert(array_equal(sorted([], 'numeric'), [])) + assert(array_equal(sorted([5, 25, 125, 625], 'numeric'), [5, 25, 125, 625])) + assert(array_equal(sorted([125, 25, 5, 625], 'numeric'), [5, 25, 125, 625])) + }) + await t.step('sorted(..., "lexical") function', () => { + const sorted = expressionFunctions.sorted + const array_equal = (a: any[], b: any[]) => + a.length === b.length && a.every((v, i) => v === b[i]) + assert(array_equal(sorted([3, 2, 1], 'lexical'), [1, 2, 3])) + assert(array_equal(sorted([1, 2, 3], 'lexical'), [1, 2, 3])) + assert(array_equal(sorted(['3', '2', '1'], 'lexical'), ['1', '2', '3'])) + assert(array_equal(sorted(['1', '2', '3'], 'lexical'), ['1', '2', '3'])) + assert(array_equal(sorted([], 'lexical'), [])) + assert( + array_equal(sorted(['5', '25', '125', '625'], 'lexical'), [ + '125', + '25', + '5', + '625', + ]), + ) + assert( + array_equal(sorted(['125', '25', '5', '625'], 'lexical'), [ + '125', + '25', + '5', + '625', + ]), + ) + }) + await t.step('sorted(..., "auto") function', () => { + const sorted = expressionFunctions.sorted + const array_equal = (a: any[], b: any[]) => + a.length === b.length && a.every((v, i) => v === b[i]) + assert(array_equal(sorted([125, 25, 5, 625], 'auto'), [5, 25, 125, 625])) + assert( + array_equal(sorted(['5', '25', '125', '625'], 'auto'), [ + '125', + '25', + '5', + '625', + ]), + ) + }) + await t.step('allequal function', () => { + const allequal = expressionFunctions.allequal + assert(allequal([1, 2, 1], [1, 2, 1]) === true) + assert(allequal([1, 2, 1], [1, 2, 3]) === false) + assert(allequal(['a', 'b', 'a'], ['a', 'b', 'a']) === true) + assert(allequal(['a', 'b', 'a'], ['a', 'b', 'c']) === false) + assert(allequal([1, 2, 1], [1, 2, 1, 2]) === false) + assert(allequal([1, 2, 1, 2], [1, 2, 1]) === false) + }) +}) diff --git a/bids-validator/src/schema/expressionLanguage.ts b/bids-validator/src/schema/expressionLanguage.ts index f28f53bbd..aa6596a3f 100644 --- a/bids-validator/src/schema/expressionLanguage.ts +++ b/bids-validator/src/schema/expressionLanguage.ts @@ -83,9 +83,19 @@ export const expressionFunctions = { } return arg.substr(start, end - start) }, - sorted: (list: T[]): T[] => { - // Use a cmp function that will work for any comparable types - return list.toSorted((a, b) => +(a > b) - +(a < b)) + sorted: (list: T[], method: string = 'auto'): T[] => { + const cmp = { + numeric: (a: T, b: T) => { + return Number(a) - Number(b) + }, + lexical: (a: T, b: T) => { + return String(a).localeCompare(String(b)) + }, + auto: (a: T, b: T) => { + return +(a > b) - +(a < b) + }, + }[method] + return list.toSorted(cmp) }, allequal: (a: T[], b: T[]): boolean => { return (a != null && b != null) && a.length === b.length && a.every((v, i) => v === b[i]) diff --git a/bids-validator/src/schema/fixtures.test.ts b/bids-validator/src/schema/fixtures.test.ts new file mode 100644 index 000000000..fea0f6aec --- /dev/null +++ b/bids-validator/src/schema/fixtures.test.ts @@ -0,0 +1,97 @@ +import { FileTree } from '../types/filetree.ts' +import { nullReadBytes } from '../tests/nullReadBytes.ts' + +const text = () => Promise.resolve('') + +const anatJson = () => + Promise.resolve( + JSON.stringify({ + rootOverwrite: 'anat', + subOverwrite: 'anat', + anatValue: 'anat', + }), + ) +const subjectJson = () => + Promise.resolve( + JSON.stringify({ subOverwrite: 'subject', subValue: 'subject' }), + ) +const rootJson = () => + Promise.resolve(JSON.stringify({ rootOverwrite: 'root', rootValue: 'root' })) + +export const rootFileTree = new FileTree('/', '') +const stimuliFileTree = new FileTree('/stimuli', 'stimuli', rootFileTree) +const subjectFileTree = new FileTree('/sub-01', 'sub-01', rootFileTree) +const sessionFileTree = new FileTree( + '/sub-01/ses-01', + 'ses-01', + subjectFileTree, +) +const anatFileTree = new FileTree( + '/sub-01/ses-01/anat', + 'anat', + sessionFileTree, +) + +export const dataFile = { + text, + path: '/sub-01/ses-01/anat/sub-01_ses-01_T1w.nii.gz', + name: 'sub-01_ses-01_T1w.nii.gz', + size: 311112, + ignored: false, + stream: new ReadableStream(), + readBytes: nullReadBytes, +} + +anatFileTree.files = [ + dataFile, + { + text: anatJson, + path: '/sub-01/ses-01/anat/sub-01_ses-01_T1w.json', + name: 'sub-01_ses-01_T1w.json', + size: 311112, + ignored: false, + stream: new ReadableStream(), + readBytes: nullReadBytes, + }, +] + +sessionFileTree.files = [] +sessionFileTree.directories = [anatFileTree] + +subjectFileTree.files = [ + { + text: subjectJson, + path: '/sub-01/ses-01_T1w.json', + name: 'ses-01_T1w.json', + size: 311112, + ignored: false, + stream: new ReadableStream(), + readBytes: nullReadBytes, + }, +] +subjectFileTree.directories = [sessionFileTree] + +stimuliFileTree.files = [...Array(10).keys()].map((i) => ( + { + text, + path: `/stimuli/stimfile${i}.png`, + name: `stimfile${i}.png`, + size: 2048, + ignored: false, + stream: new ReadableStream(), + readBytes: nullReadBytes, + } +)) + +rootFileTree.files = [ + { + text: rootJson, + path: '/T1w.json', + name: 'T1w.json', + size: 311112, + ignored: false, + stream: new ReadableStream(), + readBytes: nullReadBytes, + }, +] +rootFileTree.directories = [stimuliFileTree, subjectFileTree]