From 1dc8ad17dc6ca886b994641a7d1e3ee938bb091f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Jan 2026 00:41:30 +0000 Subject: [PATCH] engine2: expose Vensim MDL file support Add Project.openVensim() and Project.hasVensimSupport() static methods to the engine2 public API. These methods wrap the underlying libsimlin FFI functions and provide proper error handling when Vensim support is not available (e.g., when WASM is built without the vensim feature). The hasVensimSupport() check is only exposed via the async Project.hasVensimSupport() method, which ensures WASM is initialized before checking. This prevents runtime exceptions if called before initialization. Updated NewProject.tsx to use direct Vensim import when available, falling back to the xmutil MDL-to-XMILE conversion when not. This makes the code future-proof for when the browser WASM build might include Vensim support. Added comprehensive unit tests for the new Vensim APIs that properly handle both cases: when Vensim support is available and when it is not. --- src/app/NewProject.tsx | 24 +++++++--- src/engine2/src/project.ts | 49 ++++++++++++++++++- src/engine2/tests/api.test.ts | 88 +++++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 7 deletions(-) diff --git a/src/app/NewProject.tsx b/src/app/NewProject.tsx index 7cafbd49..8705f1de 100644 --- a/src/app/NewProject.tsx +++ b/src/app/NewProject.tsx @@ -142,19 +142,31 @@ export class NewProject extends React.Component { + await ensureInitialized(options.wasm); + const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; + return Project.fromVensim(bytes); + } + + /** + * Check if the WASM module was built with Vensim MDL support. + * Automatically initializes WASM if needed. + * + * @param options Optional WASM configuration + * @returns Promise resolving to true if Project.openVensim() is available + */ + static async hasVensimSupport(options: ProjectOpenOptions = {}): Promise { + await ensureInitialized(options.wasm); + return hasVensimSupport(); + } + /** * Get the internal WASM pointer. For internal use only. */ diff --git a/src/engine2/tests/api.test.ts b/src/engine2/tests/api.test.ts index cf47fe2a..32a613b8 100644 --- a/src/engine2/tests/api.test.ts +++ b/src/engine2/tests/api.test.ts @@ -34,6 +34,15 @@ function loadTestXmile(): Uint8Array { return fs.readFileSync(xmilePath); } +// Load the teacup test model in Vensim MDL format +function loadTestMdl(): Uint8Array { + const mdlPath = path.join(__dirname, '..', '..', '..', 'test', 'test-models', 'samples', 'teacup', 'teacup.mdl'); + if (!fs.existsSync(mdlPath)) { + throw new Error('Required test MDL model not found: ' + mdlPath); + } + return fs.readFileSync(mdlPath); +} + async function openTestProject(): Promise { return Project.open(loadTestXmile()); } @@ -1067,4 +1076,83 @@ describe('High-Level API', () => { project.dispose(); }); }); + + describe('Vensim MDL support', () => { + it('should check hasVensimSupport availability via Project static method', async () => { + // Project.hasVensimSupport() is an async method that ensures WASM is initialized + const supported = await Project.hasVensimSupport(); + expect(typeof supported).toBe('boolean'); + }); + + it('should handle openVensim when support is not available', async () => { + const supported = await Project.hasVensimSupport(); + + if (!supported) { + // When Vensim support is not available, openVensim should throw + const mdlData = loadTestMdl(); + await expect(Project.openVensim(mdlData)).rejects.toThrow(/vensim/i); + } + }); + + it('should load MDL file when Vensim support is available', async () => { + const supported = await Project.hasVensimSupport(); + + if (supported) { + // When Vensim support is available, openVensim should work + const mdlData = loadTestMdl(); + const project = await Project.openVensim(mdlData); + + expect(project).toBeInstanceOf(Project); + expect(project.modelCount).toBeGreaterThan(0); + + // The teacup model should have the expected variables + const model = project.mainModel; + const varNames = model.variables.map((v) => v.name.toLowerCase()); + expect(varNames).toContain('teacup temperature'); + + project.dispose(); + } + }); + + it('should accept MDL data as string when Vensim support is available', async () => { + const supported = await Project.hasVensimSupport(); + + if (supported) { + // openVensim should accept string data (like XMILE) + const mdlData = loadTestMdl(); + const mdlString = new TextDecoder().decode(mdlData); + const project = await Project.openVensim(mdlString); + + expect(project).toBeInstanceOf(Project); + project.dispose(); + } + }); + + it('should simulate models loaded from MDL when Vensim support is available', async () => { + const supported = await Project.hasVensimSupport(); + + if (supported) { + const mdlData = loadTestMdl(); + const project = await Project.openVensim(mdlData); + const model = project.mainModel; + + // Run simulation + const run = model.run(); + expect(run).toBeInstanceOf(Run); + + // Get results + const results = run.results; + expect(results.size).toBeGreaterThan(0); + + // Check that teacup temperature series exists and has expected behavior + // (temperature should decrease over time as teacup cools) + const tempSeries = results.get('teacup_temperature'); + if (tempSeries && tempSeries.length > 1) { + expect(tempSeries[0]).toBeGreaterThan(tempSeries[tempSeries.length - 1]); + } + + project.dispose(); + } + }); + }); });