solverforge-ui 0.5.1

Frontend component library for SolverForge constraint-optimization applications
Documentation
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const vm = require('node:vm');

const { createDom } = require('./support/fake-dom');

const ROOT = path.resolve(__dirname, '..');

function loadSf(files, overrides = {}) {
  const { document, window, Node } = createDom();
  const context = vm.createContext({
    console,
    document,
    window,
    Node,
    setTimeout,
    clearTimeout,
    ...overrides,
  });

  files.forEach((file) => {
    const source = fs.readFileSync(path.join(ROOT, file), 'utf8');
    vm.runInContext(source, context, { filename: file });
  });

  return { SF: context.window.SF, document };
}

test('status bars only toggle the controls on their bound header', () => {
  const { SF } = loadSf([
    'js-src/00-core.js',
    'js-src/03-buttons.js',
    'js-src/04-header.js',
    'js-src/05-statusbar.js',
  ]);

  const headerOne = SF.createHeader({ actions: { onSolve() {}, onPause() {}, onResume() {}, onCancel() {} } });
  const headerTwo = SF.createHeader({ actions: { onSolve() {}, onPause() {}, onResume() {}, onCancel() {} } });
  const barOne = SF.createStatusBar({ header: headerOne });
  const barTwo = SF.createStatusBar({ header: headerTwo });

  barOne.setLifecycleState('SOLVING');
  assert.equal(headerOne.sfControls.solveBtn.style.display, 'none');
  assert.equal(headerOne.sfControls.pauseBtn.style.display, '');
  assert.equal(headerOne.sfControls.cancelBtn.style.display, '');
  assert.equal(headerOne.sfControls.spinner.classList.contains('active'), true);
  assert.notEqual(headerTwo.sfControls.solveBtn.style.display, 'none');
  assert.equal(headerTwo.sfControls.spinner.classList.contains('active'), false);

  barTwo.setLifecycleState('PAUSED');
  assert.equal(headerTwo.sfControls.solveBtn.style.display, 'none');
  assert.equal(headerTwo.sfControls.resumeBtn.style.display, '');
  assert.equal(headerTwo.sfControls.cancelBtn.style.display, '');

  barTwo.setLifecycleState('SOLVING');
  assert.equal(headerTwo.sfControls.solveBtn.style.display, 'none');
  assert.equal(headerTwo.sfControls.pauseBtn.style.display, '');
});

test('tab switching stays scoped to the owning tab container', () => {
  const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/07-tabs.js']);

  const tabsOne = SF.createTabs({
    tabs: [
      { id: 'plan', active: true, content: 'Plan' },
      { id: 'gantt', content: 'Gantt' },
    ],
  });
  const tabsTwo = SF.createTabs({
    tabs: [
      { id: 'alpha', active: true, content: 'Alpha' },
      { id: 'beta', content: 'Beta' },
    ],
  });

  document.body.appendChild(tabsOne.el);
  document.body.appendChild(tabsTwo.el);

  tabsOne.show('gantt');
  assert.equal(tabsOne.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), false);
  assert.equal(tabsOne.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), true);
  assert.equal(tabsTwo.el.querySelector('[data-tab-id="alpha"]').classList.contains('active'), true);
  assert.equal(tabsTwo.el.querySelector('[data-tab-id="beta"]').classList.contains('active'), false);
});

test('global showTab updates every matching tab container independently', () => {
  const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/07-tabs.js']);

  const tabsOne = SF.createTabs({
    tabs: [
      { id: 'plan', active: true, content: 'Plan A' },
      { id: 'gantt', content: 'Gantt A' },
    ],
  });
  const tabsTwo = SF.createTabs({
    tabs: [
      { id: 'plan', active: true, content: 'Plan B' },
      { id: 'gantt', content: 'Gantt B' },
    ],
  });

  document.body.appendChild(tabsOne.el);
  document.body.appendChild(tabsTwo.el);

  SF.showTab('gantt');
  assert.equal(tabsOne.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), false);
  assert.equal(tabsOne.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), true);
  assert.equal(tabsTwo.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), false);
  assert.equal(tabsTwo.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), true);
});

test('root-scoped showTab only updates the targeted tab container', () => {
  const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/07-tabs.js']);

  const tabsOne = SF.createTabs({
    tabs: [
      { id: 'plan', active: true, content: 'Plan A' },
      { id: 'gantt', content: 'Gantt A' },
    ],
  });
  const tabsTwo = SF.createTabs({
    tabs: [
      { id: 'plan', active: true, content: 'Plan B' },
      { id: 'gantt', content: 'Gantt B' },
    ],
  });

  document.body.appendChild(tabsOne.el);
  document.body.appendChild(tabsTwo.el);

  SF.showTab('gantt', tabsOne.el);
  assert.equal(tabsOne.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), false);
  assert.equal(tabsOne.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), true);
  assert.equal(tabsTwo.el.querySelector('[data-tab-id="plan"]').classList.contains('active'), true);
  assert.equal(tabsTwo.el.querySelector('[data-tab-id="gantt"]').classList.contains('active'), false);
});

test('gantt instances get unique generated IDs by default', () => {
  const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js']);

  const ganttOne = SF.gantt.create({});
  const ganttTwo = SF.gantt.create({});
  const onePanes = ganttOne.el.querySelectorAll('.sf-gantt-pane');
  const twoPanes = ganttTwo.el.querySelectorAll('.sf-gantt-pane');
  const oneContainer = ganttOne.el.querySelector('.sf-gantt-container');
  const twoContainer = ganttTwo.el.querySelector('.sf-gantt-container');

  assert.equal(onePanes[0].id === twoPanes[0].id, false);
  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);
});

test('gantt remount recreates the chart and preserves refresh behavior', () => {
  const splitCalls = [];
  const refreshCalls = [];
  let ganttInstanceCount = 0;

  const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], {
    Split: function (targets, options) {
      splitCalls.push({ targets, options });
      return {
        destroy() {},
      };
    },
    Gantt: function () {
      ganttInstanceCount++;
      return {
        change_view_mode() {},
        refresh(tasks) {
          refreshCalls.push(tasks);
        },
      };
    },
  });

  const mountOne = document.createElement('div');
  const mountTwo = document.createElement('div');
  document.body.appendChild(mountOne);
  document.body.appendChild(mountTwo);

  const gantt = SF.gantt.create({});
  gantt.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]);
  gantt.mount(mountOne);
  gantt.mount(mountTwo);
  gantt.refresh();

  assert.equal(ganttInstanceCount >= 2, true);
  assert.equal(mountOne.childNodes.includes(gantt.el), false);
  assert.equal(mountTwo.childNodes.includes(gantt.el), true);
  assert.notEqual(gantt.getChart(), null);
  assert.equal(refreshCalls.length, 1);
  assert.equal(splitCalls.length, 2);
});

test('failed gantt remount keeps the existing mounted chart intact', () => {
  let destroyCount = 0;

  const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], {
    Split: function () {
      return {
        destroy() {
          destroyCount++;
        },
      };
    },
    Gantt: function () {
      return {
        change_view_mode() {},
        refresh() {},
      };
    },
  });

  const validMount = document.createElement('div');
  const hiddenMount = document.createElement('div');
  hiddenMount.clientWidth = 0;
  hiddenMount.clientHeight = 0;
  hiddenMount.offsetWidth = 0;
  hiddenMount.offsetHeight = 0;
  document.body.appendChild(validMount);
  document.body.appendChild(hiddenMount);

  const gantt = SF.gantt.create({});
  gantt.setTasks([{ id: 'task-1', start: '2026-03-21', end: '2026-03-22' }]);
  gantt.mount(validMount);

  assert.throws(function () {
    gantt.mount(hiddenMount);
  }, /target is not laid out yet/);
  assert.equal(validMount.childNodes.includes(gantt.el), true);
  assert.equal(hiddenMount.childNodes.includes(gantt.el), false);
  assert.equal(destroyCount, 0);
});

test('gantt initSplit keeps accepting scalar splitMinSize values', () => {
  const splitCalls = [];

  const { SF, document } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], {
    Split: function (targets, options) {
      splitCalls.push({ targets, options });
      return {
        destroy() {},
      };
    },
  });

  const mount = document.createElement('div');
  document.body.appendChild(mount);

  const gantt = SF.gantt.create({ splitMinSize: 160 });
  gantt.mount(mount);

  assert.equal(splitCalls.length, 1);
  assert.equal(splitCalls[0].options.minSize[0], 160);
  assert.equal(splitCalls[0].options.minSize[1], 160);
});

test('gantt sortable columns render and reorder grid rows without throwing', () => {
  const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], {
    Gantt: function () {
      return {
        change_view_mode() {},
        refresh() {},
      };
    },
  });

  const gantt = SF.gantt.create({
    columns: [
      { key: 'name', label: 'Task', sortable: true },
      { key: 'start', label: 'Start' },
    ],
  });

  gantt.setTasks([
    { id: 'b', name: 'Beta', start: '2026-03-22', end: '2026-03-23' },
    { id: 'a', name: 'Alpha', start: '2026-03-21', end: '2026-03-22' },
  ]);

  const header = gantt.el.querySelector('th');
  header.click();

  const rows = gantt.el.querySelectorAll('.sf-gantt-row');
  assert.equal(rows[0].dataset.taskId, 'a');
  assert.equal(rows[1].dataset.taskId, 'b');
});

test('gantt pinned tasks propagate pinned custom class to chart tasks', () => {
  let seenTasks = null;

  const { SF } = loadSf(['js-src/00-core.js', 'js-src/14-gantt.js'], {
    Gantt: function (_selector, tasks) {
      seenTasks = tasks;
      return {
        change_view_mode() {},
        refresh() {},
      };
    },
  });

  const gantt = SF.gantt.create({});
  gantt.setTasks([
    { id: 'task-1', start: '2026-03-21', end: '2026-03-22', pinned: true, custom_class: 'critical' },
  ]);

  assert.equal(seenTasks[0].custom_class, 'critical pinned');
});