diff --git a/Examples/cs/cspidcontroller/cs_pid_controller.bngl b/Examples/cs/cspidcontroller/cs_pid_controller.bngl index 23847ab9..38906937 100644 --- a/Examples/cs/cspidcontroller/cs_pid_controller.bngl +++ b/Examples/cs/cspidcontroller/cs_pid_controller.bngl @@ -78,8 +78,7 @@ begin functions P_term() + I_term() + D_term(),0) # Disturbance function (step at t=disturb_time) - # Hack: use a very steep sigmoid to approximate step function - Disturb_rate() = disturb_mag / (1 + exp(-10*(t - disturb_time))) + Disturb_rate() = if(t > disturb_time, disturb_mag, 0) end functions begin reaction rules diff --git a/scripts/generate-manifest.js b/scripts/generate-manifest.js index a9f81c6f..720f5574 100644 --- a/scripts/generate-manifest.js +++ b/scripts/generate-manifest.js @@ -1,6 +1,6 @@ const fs = require('fs'); const path = require('path'); -const { listModelFiles } = require('./utils'); +const { listModelFiles, listModelFilesAsync } = require('./utils'); const SEARCH_ROOTS = ['Published', 'Examples', 'Tutorials']; @@ -143,31 +143,38 @@ function buildEntry(root, metadata, metadataFile, modelFile, isCollection) { }; } -function main() { +async function main() { const { root, output } = parseArgs(process.argv.slice(2)); const metadataFiles = SEARCH_ROOTS.flatMap(searchRoot => listMetadataFiles(path.join(root, searchRoot))); - const manifestEntries = []; - for (const metadataFile of metadataFiles) { - const metadata = parseMetadataYaml(fs.readFileSync(metadataFile, 'utf8')); - const modelFiles = listModelFiles(path.dirname(metadataFile)); - if (modelFiles.length === 0) continue; + const entryPromises = metadataFiles.map(async (metadataFile) => { + const content = await fs.promises.readFile(metadataFile, 'utf8'); + const metadata = parseMetadataYaml(content); + const modelFiles = await listModelFilesAsync(path.dirname(metadataFile)); + + if (modelFiles.length === 0) return []; const isCollection = modelFiles.length > 1 || Boolean(metadata.collection); - for (const modelFile of modelFiles) { - manifestEntries.push(buildEntry(root, metadata, metadataFile, modelFile, isCollection)); - } - } + return modelFiles.map(modelFile => + buildEntry(root, metadata, metadataFile, modelFile, isCollection) + ); + }); + + const manifestEntries = (await Promise.all(entryPromises)).flat(); manifestEntries.sort((left, right) => left.id.localeCompare(right.id)); - fs.writeFileSync(output, JSON.stringify(manifestEntries, null, 2)); + await fs.promises.writeFile(output, JSON.stringify(manifestEntries, null, 2)); console.log(`Generated ${manifestEntries.length} manifest entries at ${output}`); } if (require.main === module) { - main(); + main().catch((err) => { + console.error(err); + process.exit(1); + }); } module.exports = { buildEntry, + parseMetadataYaml, }; \ No newline at end of file diff --git a/scripts/generate-manifest.test.js b/scripts/generate-manifest.test.js index eeef5520..2cc080a1 100644 --- a/scripts/generate-manifest.test.js +++ b/scripts/generate-manifest.test.js @@ -1,7 +1,7 @@ const test = require('node:test'); const assert = require('node:assert'); const path = require('path'); -const { buildEntry } = require('./generate-manifest.js'); +const { buildEntry, parseMetadataYaml } = require('./generate-manifest.js'); test('buildEntry', async (t) => { await t.test('handles a single model with full metadata', () => { @@ -94,3 +94,109 @@ test('buildEntry', async (t) => { assert.strictEqual(entry.visible, false); }); }); + +test('parseMetadataYaml', async (t) => { + await t.test('parses basic key-value pairs', () => { + const yaml = ` +id: my-model +name: "My Model" +description: A test model +featured: true +count: 42 + `; + const result = parseMetadataYaml(yaml); + assert.deepStrictEqual(result, { + id: 'my-model', + name: 'My Model', + description: 'A test model', + featured: true, + count: 42 + }); + }); + + await t.test('ignores empty lines and comments', () => { + const yaml = ` +# This is a comment +id: model-1 + +# Another comment + +name: test + `; + const result = parseMetadataYaml(yaml); + assert.deepStrictEqual(result, { + id: 'model-1', + name: 'test' + }); + }); + + await t.test('parses list arrays (tags)', () => { + const yaml = ` +id: model-tags +tags: + - biology + - physics + - chemistry + `; + const result = parseMetadataYaml(yaml); + assert.deepStrictEqual(result, { + id: 'model-tags', + tags: ['biology', 'physics', 'chemistry'] + }); + }); + + await t.test('parses nested objects', () => { + const yaml = ` +id: nested-model +compatibility: + bng2_compatible: true + simulation_methods: [ode, ssa] +source: + origin: published + original_repository: "http://example.com" + `; + const result = parseMetadataYaml(yaml); + assert.deepStrictEqual(result, { + id: 'nested-model', + compatibility: { + bng2_compatible: true, + simulation_methods: ['ode', 'ssa'] + }, + source: { + origin: 'published', + original_repository: 'http://example.com' + } + }); + }); + + await t.test('parses deeply nested objects', () => { + const yaml = ` +a: + b: + c: + d: value + `; + const result = parseMetadataYaml(yaml); + assert.deepStrictEqual(result, { + a: { + b: { + c: { + d: 'value' + } + } + } + }); + }); + + await t.test('handles empty tags array', () => { + const yaml = ` +id: empty-tags +tags: + `; + const result = parseMetadataYaml(yaml); + assert.deepStrictEqual(result, { + id: 'empty-tags', + tags: [] + }); + }); +}); diff --git a/scripts/utils.js b/scripts/utils.js index d0f4c2ce..e64c34a9 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -7,6 +7,15 @@ function listModelFiles(dir) { .sort(); } +async function listModelFilesAsync(dir) { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.bngl')) + .map((entry) => entry.name) + .sort(); +} + module.exports = { - listModelFiles + listModelFiles, + listModelFilesAsync }; diff --git a/scripts/utils.test.js b/scripts/utils.test.js new file mode 100644 index 00000000..8f6fc911 --- /dev/null +++ b/scripts/utils.test.js @@ -0,0 +1,52 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { listModelFiles } = require('./utils.js'); + +test('utils.js', async (t) => { + let tmpDir; + + t.beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bionetgen-utils-test-')); + }); + + t.afterEach(() => { + if (tmpDir) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + await t.test('listModelFiles returns only .bngl files in alphabetical order', () => { + fs.writeFileSync(path.join(tmpDir, 'model2.bngl'), 'content'); + fs.writeFileSync(path.join(tmpDir, 'model1.bngl'), 'content'); + fs.writeFileSync(path.join(tmpDir, 'model3.bngl'), 'content'); + + const files = listModelFiles(tmpDir); + assert.deepStrictEqual(files, ['model1.bngl', 'model2.bngl', 'model3.bngl']); + }); + + await t.test('listModelFiles ignores directories even if named with .bngl extension', () => { + fs.writeFileSync(path.join(tmpDir, 'model1.bngl'), 'content'); + fs.mkdirSync(path.join(tmpDir, 'dir.bngl')); + fs.mkdirSync(path.join(tmpDir, 'other-dir')); + + const files = listModelFiles(tmpDir); + assert.deepStrictEqual(files, ['model1.bngl']); + }); + + await t.test('listModelFiles ignores files with other extensions', () => { + fs.writeFileSync(path.join(tmpDir, 'model1.bngl'), 'content'); + fs.writeFileSync(path.join(tmpDir, 'data.txt'), 'content'); + fs.writeFileSync(path.join(tmpDir, 'model2.xml'), 'content'); + + const files = listModelFiles(tmpDir); + assert.deepStrictEqual(files, ['model1.bngl']); + }); + + await t.test('listModelFiles returns empty array for empty directory', () => { + const files = listModelFiles(tmpDir); + assert.deepStrictEqual(files, []); + }); +}); diff --git a/scripts/validate-metadata.js b/scripts/validate-metadata.js index 6cb8645c..355f7e87 100644 --- a/scripts/validate-metadata.js +++ b/scripts/validate-metadata.js @@ -93,7 +93,7 @@ function parseMetadataYaml(content) { const trimmed = rawLine.trim(); if (trimmed.startsWith('- ')) { - const currentPath = stack.map((entry) => entry.key).join('.'); + const currentPath = stack.length > 0 ? stack[stack.length - 1].path : ''; const listValue = parseScalar(trimmed.slice(2)); if (currentPath === 'tags') { result.tags = Array.isArray(result.tags) ? result.tags : []; @@ -111,10 +111,10 @@ function parseMetadataYaml(content) { const key = trimmed.slice(0, separator).trim(); const rawValue = trimmed.slice(separator + 1); - const dottedPath = [...stack.map((entry) => entry.key), key].join('.'); + const dottedPath = stack.length > 0 ? stack[stack.length - 1].path + '.' + key : key; if (!rawValue.trim()) { - stack.push({ key, indent }); + stack.push({ key, indent, path: dottedPath }); if (dottedPath === 'tags') { result.tags = Array.isArray(result.tags) ? result.tags : []; } @@ -275,4 +275,5 @@ module.exports = { parseScalar, validateMetadataFile, parseMetadataYaml, + listMetadataFiles, }; \ No newline at end of file diff --git a/scripts/validate-metadata.test.js b/scripts/validate-metadata.test.js index f161308d..f63a422b 100644 --- a/scripts/validate-metadata.test.js +++ b/scripts/validate-metadata.test.js @@ -1,6 +1,6 @@ const test = require('node:test'); const assert = require('node:assert'); -const { parseMetadataYaml } = require('./validate-metadata.js'); +const { parseMetadataYaml, listMetadataFiles } = require('./validate-metadata.js'); test('parseMetadataYaml', async (t) => { await t.test('parses basic key-value pairs', () => { @@ -107,3 +107,11 @@ tags: }); }); }); + +test('listMetadataFiles', async (t) => { + await t.test('returns empty array for non-existent directory', () => { + const nonExistentPath = '/path/that/does/not/exist/for/sure/12345'; + const result = listMetadataFiles(nonExistentPath); + assert.deepStrictEqual(result, []); + }); +}); diff --git a/tests/validate-metadata.test.js b/tests/validate-metadata.test.js index 748d276e..f0aaa47a 100644 --- a/tests/validate-metadata.test.js +++ b/tests/validate-metadata.test.js @@ -57,6 +57,91 @@ test('valid metadata file passes validation without errors', () => { }); }); +test('invalid enum values for expectEnum fields add errors', () => { + withTempDir((tempDir) => { + const metadataFile = path.join(tempDir, 'metadata.yaml'); + + // Test with wrong types (e.g. number, boolean, null instead of string) + const invalidTypeYaml = ` +id: "test-model" +name: "Test Model" +description: "A test model" +tags: [] +category: 123 +compatibility: + bng2_compatible: true + uses_compartments: false + uses_energy: false + uses_functions: true + nfsim_compatible: false + simulation_methods: [ode] +source: + origin: true + original_repository: "repo" +playground: + visible: true + gallery_category: "Test" + featured: false + difficulty: null +collection: + type: 456 + parent_model: "test-model" + variant_key: "test" + count: 2 +`; + fs.writeFileSync(metadataFile, invalidTypeYaml); + fs.writeFileSync(path.join(tempDir, 'README.md'), '# Test Model'); + fs.writeFileSync(path.join(tempDir, 'model1.bngl'), ''); + fs.writeFileSync(path.join(tempDir, 'model2.bngl'), ''); + + let errors = []; + validateMetadataFile(metadataFile, errors); + + assert.ok(errors.some(e => e.includes('invalid category (123)')), 'Should report invalid category type'); + assert.ok(errors.some(e => e.includes('invalid source.origin (true)')), 'Should report invalid origin type'); + assert.ok(errors.some(e => e.includes('invalid playground.difficulty (null)')), 'Should report invalid difficulty type'); + assert.ok(errors.some(e => e.includes('invalid collection.type (456)')), 'Should report invalid collection type'); + + // Test with string values not in the allowed sets + const invalidStringYaml = ` +id: "test-model" +name: "Test Model" +description: "A test model" +tags: [] +category: "not-a-real-category" +compatibility: + bng2_compatible: true + uses_compartments: false + uses_energy: false + uses_functions: true + nfsim_compatible: false + simulation_methods: [ode] +source: + origin: "fake-origin" + original_repository: "repo" +playground: + visible: true + gallery_category: "Test" + featured: false + difficulty: "extremely-hard" +collection: + type: "unknown-collection-type" + parent_model: "test-model" + variant_key: "test" + count: 2 +`; + fs.writeFileSync(metadataFile, invalidStringYaml); + + errors = []; + validateMetadataFile(metadataFile, errors); + + assert.ok(errors.some(e => e.includes('invalid category ("not-a-real-category")')), 'Should report invalid category string'); + assert.ok(errors.some(e => e.includes('invalid source.origin ("fake-origin")')), 'Should report invalid origin string'); + assert.ok(errors.some(e => e.includes('invalid playground.difficulty ("extremely-hard")')), 'Should report invalid difficulty string'); + assert.ok(errors.some(e => e.includes('invalid collection.type ("unknown-collection-type")')), 'Should report invalid collection type string'); + }); +}); + test('missing README.md adds error', () => { withTempDir((tempDir) => { const metadataFile = path.join(tempDir, 'metadata.yaml');