Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions Examples/cs/cspidcontroller/cs_pid_controller.bngl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 20 additions & 13 deletions scripts/generate-manifest.js
Original file line number Diff line number Diff line change
@@ -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'];

Expand Down Expand Up @@ -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,
};
108 changes: 107 additions & 1 deletion scripts/generate-manifest.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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: []
});
});
});
11 changes: 10 additions & 1 deletion scripts/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
52 changes: 52 additions & 0 deletions scripts/utils.test.js
Original file line number Diff line number Diff line change
@@ -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, []);
});
});
7 changes: 4 additions & 3 deletions scripts/validate-metadata.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 : [];
Expand All @@ -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 : [];
}
Expand Down Expand Up @@ -275,4 +275,5 @@ module.exports = {
parseScalar,
validateMetadataFile,
parseMetadataYaml,
listMetadataFiles,
};
10 changes: 9 additions & 1 deletion scripts/validate-metadata.test.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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, []);
});
});
85 changes: 85 additions & 0 deletions tests/validate-metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading