diff --git a/js-src/00-core.js b/js-src/00-core.js index e774602..5ff4e57 100644 --- a/js-src/00-core.js +++ b/js-src/00-core.js @@ -19,6 +19,10 @@ const SF = (function () { .replace(/"/g, '"'); }; + sf.assert = function (cond, message) { + if (!cond) throw new Error('[SolverForge] ' + message); + }; + sf.el = function (tag, attrs) { var children = Array.prototype.slice.call(arguments, 2); var el = document.createElement(tag); diff --git a/js-src/03-buttons.js b/js-src/03-buttons.js index 583522b..842f295 100644 --- a/js-src/03-buttons.js +++ b/js-src/03-buttons.js @@ -6,6 +6,8 @@ 'use strict'; sf.createButton = function (config) { + sf.assert(config, 'createButton(config) requires a configuration object'); + var classes = ['sf-btn']; if (config.variant) classes.push('sf-btn--' + config.variant); @@ -23,6 +25,8 @@ if (config.disabled) btn.disabled = true; + sf.assert(!config.onClick || typeof config.onClick === 'function', 'createButton(onClick) must be a function'); + if (config.icon) { var icon = sf.el('i', { className: 'fa-solid ' + config.icon }); btn.appendChild(icon); diff --git a/js-src/04-header.js b/js-src/04-header.js index 95d6187..4c6c62a 100644 --- a/js-src/04-header.js +++ b/js-src/04-header.js @@ -6,6 +6,8 @@ 'use strict'; sf.createHeader = function (config) { + sf.assert(config, 'createHeader(config) requires a configuration object'); + var header = sf.el('header', { className: 'sf-header' }); var controls = { actions: null, @@ -38,9 +40,12 @@ // Nav tabs if (config.tabs && config.tabs.length > 0) { + sf.assert(Array.isArray(config.tabs), 'createHeader(config.tabs) expects an array'); var nav = sf.el('nav', { className: 'sf-header-nav' }); controls.nav = nav; config.tabs.forEach(function (tab) { + sf.assert(tab && tab.id, 'createHeader tab entries require an id'); + sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); var btn = sf.el('button', { className: 'sf-nav-btn' + (tab.active ? ' active' : ''), dataset: { tab: tab.id }, @@ -61,6 +66,12 @@ // Action buttons if (config.actions) { + sf.assert(typeof config.actions === 'object', 'createHeader(config.actions) expects an object'); + sf.assert(!config.actions.onSolve || typeof config.actions.onSolve === 'function', 'createHeader(config.actions.onSolve) must be a function'); + sf.assert(!config.actions.onStop || typeof config.actions.onStop === 'function', 'createHeader(config.actions.onStop) must be a function'); + sf.assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === 'function', 'createHeader(config.actions.onAnalyze) must be a function'); + sf.assert(!config.onTabChange || typeof config.onTabChange === 'function', 'createHeader(config.onTabChange) must be a function'); + var actions = sf.el('div', { className: 'sf-header-actions' }); controls.actions = actions; diff --git a/js-src/06-modal.js b/js-src/06-modal.js index 9698d0c..8c8c096 100644 --- a/js-src/06-modal.js +++ b/js-src/06-modal.js @@ -6,6 +6,9 @@ 'use strict'; sf.createModal = function (config) { + sf.assert(config, 'createModal(config) requires a configuration object'); + sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); + var overlay = sf.el('div', { className: 'sf-modal-overlay' }); var dialog = sf.el('div', { className: 'sf-modal' }); var body = sf.el('div', { className: 'sf-modal-body' }); diff --git a/js-src/07-tabs.js b/js-src/07-tabs.js index 1048dae..c1dc694 100644 --- a/js-src/07-tabs.js +++ b/js-src/07-tabs.js @@ -17,6 +17,9 @@ }; sf.createTabs = function (config) { + sf.assert(config, 'createTabs(config) requires a configuration object'); + sf.assert(Array.isArray(config.tabs), 'createTabs(config.tabs) must be an array'); + var container = sf.el('div', { className: 'sf-tabs-container' }); var tabsId = sf.uid('sf-tabs'); diff --git a/js-src/08-table.js b/js-src/08-table.js index 430ab89..5bdd770 100644 --- a/js-src/08-table.js +++ b/js-src/08-table.js @@ -6,6 +6,10 @@ 'use strict'; sf.createTable = function (config) { + sf.assert(config, 'createTable(config) requires a configuration object'); + sf.assert(!config.columns || Array.isArray(config.columns), 'createTable(config.columns) must be an array'); + sf.assert(!config.rows || Array.isArray(config.rows), 'createTable(config.rows) must be an array'); + var wrapper = sf.el('div', { className: 'sf-table-container' }); var table = sf.el('table', { className: 'sf-table' }); diff --git a/js-src/09-toast.js b/js-src/09-toast.js index d7e85ac..112d469 100644 --- a/js-src/09-toast.js +++ b/js-src/09-toast.js @@ -15,6 +15,8 @@ } sf.showToast = function (config) { + sf.assert(config, 'showToast(config) requires a configuration object'); + ensureContainer(); var variant = config.variant || 'danger'; diff --git a/js-src/10-backend.js b/js-src/10-backend.js index 0c7a9cb..639ae8a 100644 --- a/js-src/10-backend.js +++ b/js-src/10-backend.js @@ -7,6 +7,7 @@ 'use strict'; sf.createBackend = function (config) { + config = config || {}; var type = config.type || 'axum'; if (type === 'tauri') return createTauriBackend(config); return createHttpBackend(config); @@ -97,6 +98,10 @@ /* ── Tauri IPC backend ── */ function createTauriBackend(config) { + sf.assert(typeof config === 'object', 'createBackend({}) is required for Tauri adapter'); + sf.assert(typeof config.invoke === 'function', 'Tauri backend requires config.invoke'); + sf.assert(typeof config.listen === 'function', 'Tauri backend requires config.listen'); + var invoke = config.invoke; var listen = config.listen; var commands = config.commands || {}; diff --git a/js-src/11-solver.js b/js-src/11-solver.js index bf15201..d42dec6 100644 --- a/js-src/11-solver.js +++ b/js-src/11-solver.js @@ -7,6 +7,16 @@ 'use strict'; sf.createSolver = function (config) { + sf.assert(config, 'createSolver(config) requires a configuration object'); + sf.assert(config.backend, 'createSolver(config.backend) is required'); + sf.assert(config.backend.createSchedule && typeof config.backend.createSchedule === 'function', 'createSolver(config.backend.createSchedule) must be a function'); + sf.assert(config.backend.streamEvents && typeof config.backend.streamEvents === 'function', 'createSolver(config.backend.streamEvents) must be a function'); + sf.assert(config.backend.getSchedule && typeof config.backend.getSchedule === 'function', 'createSolver(config.backend.getSchedule) must be a function'); + sf.assert(!config.onUpdate || typeof config.onUpdate === 'function', 'createSolver(config.onUpdate) must be a function'); + sf.assert(!config.onComplete || typeof config.onComplete === 'function', 'createSolver(config.onComplete) must be a function'); + sf.assert(!config.onAnalysis || typeof config.onAnalysis === 'function', 'createSolver(config.onAnalysis) must be a function'); + sf.assert(!config.onError || typeof config.onError === 'function', 'createSolver(config.onError) must be a function'); + var backend = config.backend; var statusBar = config.statusBar; var closeStream = null; diff --git a/js-src/12-api-guide.js b/js-src/12-api-guide.js index 587c407..b350660 100644 --- a/js-src/12-api-guide.js +++ b/js-src/12-api-guide.js @@ -7,8 +7,11 @@ 'use strict'; sf.createApiGuide = function (config) { + sf.assert(config, 'createApiGuide(config) requires a configuration object'); + sf.assert(Array.isArray(config.endpoints), 'createApiGuide(config.endpoints) must be an array'); + var guide = sf.el('div', { className: 'sf-api-guide' }); - var endpoints = config.endpoints || []; + var endpoints = config.endpoints; endpoints.forEach(function (ep) { var section = sf.el('div', { className: 'sf-api-section' }); @@ -40,6 +43,8 @@ }; sf.createFooter = function (config) { + sf.assert(config, 'createFooter(config) requires a configuration object'); + var footer = sf.el('footer', { className: 'sf-footer' }); if (config.links) { config.links.forEach(function (link, i) { diff --git a/js-src/13-rail.js b/js-src/13-rail.js index a89973b..3908285 100644 --- a/js-src/13-rail.js +++ b/js-src/13-rail.js @@ -9,6 +9,9 @@ sf.rail = {}; sf.rail.createHeader = function (config) { + sf.assert(config, 'createHeader(config) requires a configuration object'); + sf.assert(!config.columns || Array.isArray(config.columns), 'createHeader(config.columns) expects an array'); + var labelWidth = config.labelWidth || 200; var columns = config.columns || []; @@ -32,6 +35,8 @@ }; sf.rail.createCard = function (config) { + sf.assert(config, 'createCard(config) requires a configuration object'); + var labelWidth = config.labelWidth || 200; var card = sf.el('div', { className: 'sf-resource-card' }); @@ -136,6 +141,10 @@ }; sf.rail.addBlock = function (rail, config) { + sf.assert(rail, 'addBlock(rail) requires a rail element'); + sf.assert(config && config.horizon != null, 'addBlock(config.horizon) is required'); + sf.assert(config.start != null && config.end != null, 'addBlock(config.start/config.end) are required'); + var horizon = config.horizon || 1; var startPct = (config.start / horizon) * 100; var widthPct = ((config.end - config.start) / horizon) * 100; @@ -175,6 +184,10 @@ }; sf.rail.addChangeover = function (rail, config) { + sf.assert(rail, 'addChangeover(rail) requires a rail element'); + sf.assert(config && config.horizon != null, 'addChangeover(config.horizon) is required'); + sf.assert(config.start != null && config.end != null, 'addChangeover(config.start/config.end) are required'); + var horizon = config.horizon || 1; var startPct = (config.start / horizon) * 100; var widthPct = ((config.end - config.start) / horizon) * 100; diff --git a/js-src/14-gantt.js b/js-src/14-gantt.js index e5108b5..8177626 100644 --- a/js-src/14-gantt.js +++ b/js-src/14-gantt.js @@ -9,6 +9,7 @@ sf.gantt = {}; sf.gantt.create = function (config) { + config = config || {}; var instanceId = sf.uid('sf-gantt'); var chartPaneId = config.chartPane || (instanceId + '-chart-pane'); var gridPaneId = config.gridPane || (instanceId + '-grid-pane'); @@ -78,12 +79,15 @@ var ctrl = { el: wrapper }; ctrl.mount = function (parent) { + sf.assert(parent, 'gantt.mount(parent) requires a mount target'); var target = typeof parent === 'string' ? document.getElementById(parent) : parent; + sf.assert(target, 'gantt.mount(parent) target not found: ' + parent); target.appendChild(wrapper); initSplit(); }; ctrl.setTasks = function (newTasks) { + sf.assert(Array.isArray(newTasks), 'gantt.setTasks(tasks) expects an array'); tasks = newTasks; renderGrid(newTasks); renderChart(newTasks); diff --git a/static/sf/sf.js b/static/sf/sf.js index acec9cb..7e4db60 100644 --- a/static/sf/sf.js +++ b/static/sf/sf.js @@ -19,6 +19,10 @@ const SF = (function () { .replace(/"/g, '"'); }; + sf.assert = function (cond, message) { + if (!cond) throw new Error('[SolverForge] ' + message); + }; + sf.el = function (tag, attrs) { var children = Array.prototype.slice.call(arguments, 2); var el = document.createElement(tag); @@ -172,6 +176,8 @@ const SF = (function () { 'use strict'; sf.createButton = function (config) { + sf.assert(config, 'createButton(config) requires a configuration object'); + var classes = ['sf-btn']; if (config.variant) classes.push('sf-btn--' + config.variant); @@ -189,6 +195,8 @@ const SF = (function () { if (config.disabled) btn.disabled = true; + sf.assert(!config.onClick || typeof config.onClick === 'function', 'createButton(onClick) must be a function'); + if (config.icon) { var icon = sf.el('i', { className: 'fa-solid ' + config.icon }); btn.appendChild(icon); @@ -226,6 +234,8 @@ const SF = (function () { 'use strict'; sf.createHeader = function (config) { + sf.assert(config, 'createHeader(config) requires a configuration object'); + var header = sf.el('header', { className: 'sf-header' }); var controls = { actions: null, @@ -258,9 +268,12 @@ const SF = (function () { // Nav tabs if (config.tabs && config.tabs.length > 0) { + sf.assert(Array.isArray(config.tabs), 'createHeader(config.tabs) expects an array'); var nav = sf.el('nav', { className: 'sf-header-nav' }); controls.nav = nav; config.tabs.forEach(function (tab) { + sf.assert(tab && tab.id, 'createHeader tab entries require an id'); + sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); var btn = sf.el('button', { className: 'sf-nav-btn' + (tab.active ? ' active' : ''), dataset: { tab: tab.id }, @@ -281,6 +294,12 @@ const SF = (function () { // Action buttons if (config.actions) { + sf.assert(typeof config.actions === 'object', 'createHeader(config.actions) expects an object'); + sf.assert(!config.actions.onSolve || typeof config.actions.onSolve === 'function', 'createHeader(config.actions.onSolve) must be a function'); + sf.assert(!config.actions.onStop || typeof config.actions.onStop === 'function', 'createHeader(config.actions.onStop) must be a function'); + sf.assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === 'function', 'createHeader(config.actions.onAnalyze) must be a function'); + sf.assert(!config.onTabChange || typeof config.onTabChange === 'function', 'createHeader(config.onTabChange) must be a function'); + var actions = sf.el('div', { className: 'sf-header-actions' }); controls.actions = actions; @@ -483,6 +502,9 @@ const SF = (function () { 'use strict'; sf.createModal = function (config) { + sf.assert(config, 'createModal(config) requires a configuration object'); + sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); + var overlay = sf.el('div', { className: 'sf-modal-overlay' }); var dialog = sf.el('div', { className: 'sf-modal' }); var body = sf.el('div', { className: 'sf-modal-body' }); @@ -585,6 +607,9 @@ const SF = (function () { }; sf.createTabs = function (config) { + sf.assert(config, 'createTabs(config) requires a configuration object'); + sf.assert(Array.isArray(config.tabs), 'createTabs(config.tabs) must be an array'); + var container = sf.el('div', { className: 'sf-tabs-container' }); var tabsId = sf.uid('sf-tabs'); @@ -628,6 +653,10 @@ const SF = (function () { 'use strict'; sf.createTable = function (config) { + sf.assert(config, 'createTable(config) requires a configuration object'); + sf.assert(!config.columns || Array.isArray(config.columns), 'createTable(config.columns) must be an array'); + sf.assert(!config.rows || Array.isArray(config.rows), 'createTable(config.rows) must be an array'); + var wrapper = sf.el('div', { className: 'sf-table-container' }); var table = sf.el('table', { className: 'sf-table' }); @@ -695,6 +724,8 @@ const SF = (function () { } sf.showToast = function (config) { + sf.assert(config, 'showToast(config) requires a configuration object'); + ensureContainer(); var variant = config.variant || 'danger'; @@ -749,6 +780,7 @@ const SF = (function () { 'use strict'; sf.createBackend = function (config) { + config = config || {}; var type = config.type || 'axum'; if (type === 'tauri') return createTauriBackend(config); return createHttpBackend(config); @@ -839,6 +871,10 @@ const SF = (function () { /* ── Tauri IPC backend ── */ function createTauriBackend(config) { + sf.assert(typeof config === 'object', 'createBackend({}) is required for Tauri adapter'); + sf.assert(typeof config.invoke === 'function', 'Tauri backend requires config.invoke'); + sf.assert(typeof config.listen === 'function', 'Tauri backend requires config.listen'); + var invoke = config.invoke; var listen = config.listen; var commands = config.commands || {}; @@ -887,6 +923,16 @@ const SF = (function () { 'use strict'; sf.createSolver = function (config) { + sf.assert(config, 'createSolver(config) requires a configuration object'); + sf.assert(config.backend, 'createSolver(config.backend) is required'); + sf.assert(config.backend.createSchedule && typeof config.backend.createSchedule === 'function', 'createSolver(config.backend.createSchedule) must be a function'); + sf.assert(config.backend.streamEvents && typeof config.backend.streamEvents === 'function', 'createSolver(config.backend.streamEvents) must be a function'); + sf.assert(config.backend.getSchedule && typeof config.backend.getSchedule === 'function', 'createSolver(config.backend.getSchedule) must be a function'); + sf.assert(!config.onUpdate || typeof config.onUpdate === 'function', 'createSolver(config.onUpdate) must be a function'); + sf.assert(!config.onComplete || typeof config.onComplete === 'function', 'createSolver(config.onComplete) must be a function'); + sf.assert(!config.onAnalysis || typeof config.onAnalysis === 'function', 'createSolver(config.onAnalysis) must be a function'); + sf.assert(!config.onError || typeof config.onError === 'function', 'createSolver(config.onError) must be a function'); + var backend = config.backend; var statusBar = config.statusBar; var closeStream = null; @@ -990,8 +1036,11 @@ const SF = (function () { 'use strict'; sf.createApiGuide = function (config) { + sf.assert(config, 'createApiGuide(config) requires a configuration object'); + sf.assert(Array.isArray(config.endpoints), 'createApiGuide(config.endpoints) must be an array'); + var guide = sf.el('div', { className: 'sf-api-guide' }); - var endpoints = config.endpoints || []; + var endpoints = config.endpoints; endpoints.forEach(function (ep) { var section = sf.el('div', { className: 'sf-api-section' }); @@ -1023,6 +1072,8 @@ const SF = (function () { }; sf.createFooter = function (config) { + sf.assert(config, 'createFooter(config) requires a configuration object'); + var footer = sf.el('footer', { className: 'sf-footer' }); if (config.links) { config.links.forEach(function (link, i) { @@ -1048,6 +1099,9 @@ const SF = (function () { sf.rail = {}; sf.rail.createHeader = function (config) { + sf.assert(config, 'createHeader(config) requires a configuration object'); + sf.assert(!config.columns || Array.isArray(config.columns), 'createHeader(config.columns) expects an array'); + var labelWidth = config.labelWidth || 200; var columns = config.columns || []; @@ -1071,6 +1125,8 @@ const SF = (function () { }; sf.rail.createCard = function (config) { + sf.assert(config, 'createCard(config) requires a configuration object'); + var labelWidth = config.labelWidth || 200; var card = sf.el('div', { className: 'sf-resource-card' }); @@ -1175,6 +1231,10 @@ const SF = (function () { }; sf.rail.addBlock = function (rail, config) { + sf.assert(rail, 'addBlock(rail) requires a rail element'); + sf.assert(config && config.horizon != null, 'addBlock(config.horizon) is required'); + sf.assert(config.start != null && config.end != null, 'addBlock(config.start/config.end) are required'); + var horizon = config.horizon || 1; var startPct = (config.start / horizon) * 100; var widthPct = ((config.end - config.start) / horizon) * 100; @@ -1214,6 +1274,10 @@ const SF = (function () { }; sf.rail.addChangeover = function (rail, config) { + sf.assert(rail, 'addChangeover(rail) requires a rail element'); + sf.assert(config && config.horizon != null, 'addChangeover(config.horizon) is required'); + sf.assert(config.start != null && config.end != null, 'addChangeover(config.start/config.end) are required'); + var horizon = config.horizon || 1; var startPct = (config.start / horizon) * 100; var widthPct = ((config.end - config.start) / horizon) * 100; @@ -1237,6 +1301,7 @@ const SF = (function () { sf.gantt = {}; sf.gantt.create = function (config) { + config = config || {}; var instanceId = sf.uid('sf-gantt'); var chartPaneId = config.chartPane || (instanceId + '-chart-pane'); var gridPaneId = config.gridPane || (instanceId + '-grid-pane'); @@ -1306,12 +1371,15 @@ const SF = (function () { var ctrl = { el: wrapper }; ctrl.mount = function (parent) { + sf.assert(parent, 'gantt.mount(parent) requires a mount target'); var target = typeof parent === 'string' ? document.getElementById(parent) : parent; + sf.assert(target, 'gantt.mount(parent) target not found: ' + parent); target.appendChild(wrapper); initSplit(); }; ctrl.setTasks = function (newTasks) { + sf.assert(Array.isArray(newTasks), 'gantt.setTasks(tasks) expects an array'); tasks = newTasks; renderGrid(newTasks); renderChart(newTasks); diff --git a/tests/backend-contract.test.js b/tests/backend-contract.test.js index 6bbd605..f15a940 100644 --- a/tests/backend-contract.test.js +++ b/tests/backend-contract.test.js @@ -64,6 +64,31 @@ test('tauri createSchedule normalizes object and numeric ids to strings', async assert.equal(await backendWithNumber.createSchedule({}), '7'); }); +test('non-tauri backend labels still use the generic HTTP adapter', async () => { + const fetchCalls = []; + const { SF } = loadSf(['js-src/00-core.js', 'js-src/10-backend.js'], { + fetch(url, opts) { + fetchCalls.push({ url, opts }); + return Promise.resolve({ + ok: true, + headers: { get() { return 'application/json'; } }, + json() { return Promise.resolve({ id: 'job-9' }); }, + }); + }, + }); + + const backend = SF.createBackend({ + type: 'rails', + baseUrl: '/api', + schedulesPath: '/jobs', + }); + + assert.equal(await backend.createSchedule({ foo: 'bar' }), 'job-9'); + assert.equal(fetchCalls.length, 1); + assert.equal(fetchCalls[0].url, '/api/jobs'); + assert.equal(fetchCalls[0].opts.method, 'POST'); +}); + test('tauri streamEvents keeps id-less updates and filters mismatched job ids', async () => { let handler = null; const received = []; diff --git a/tests/instance-local-dom.test.js b/tests/instance-local-dom.test.js index 55fe140..8c7ee8d 100644 --- a/tests/instance-local-dom.test.js +++ b/tests/instance-local-dom.test.js @@ -145,3 +145,16 @@ test('gantt instances get unique generated IDs by default', () => { assert.equal(onePanes[1].id === twoPanes[1].id, false); assert.equal(oneContainer.id === twoContainer.id, false); }); + +test('gantt.create falls back to built-in defaults when config is omitted', () => { + const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js']); + + const gantt = SF.gantt.create(); + const panes = gantt.el.querySelectorAll('.sf-gantt-pane'); + const chartContainer = gantt.el.querySelector('.sf-gantt-container'); + + assert.equal(panes.length, 2); + assert.equal(Boolean(panes[0].id), true); + assert.equal(Boolean(panes[1].id), true); + assert.equal(Boolean(chartContainer.id), true); +});