From 29f8dc04a351898bdc09306ddeba9bad4ebcf101 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Tue, 9 Jun 2026 13:08:38 +0100 Subject: [PATCH 1/6] Add combinatorial test bed progress and estimates --- apps/web/combinatorial.html | 2 + apps/web/src/combinatorial-entry.js | 565 +++++++++++++----- .../tests/jest/combinatorial-entry.test.js | 108 ++++ apps/web/styles.css | 40 ++ 4 files changed, 550 insertions(+), 165 deletions(-) create mode 100644 apps/web/src/tests/jest/combinatorial-entry.test.js diff --git a/apps/web/combinatorial.html b/apps/web/combinatorial.html index 4e037d28..a1fff1e3 100644 --- a/apps/web/combinatorial.html +++ b/apps/web/combinatorial.html @@ -43,6 +43,7 @@

Combinatorial Strategies

+
Strategies @@ -53,6 +54,7 @@

Combinatorial Strategies

Generate Combinatorial +

diff --git a/apps/web/src/combinatorial-entry.js b/apps/web/src/combinatorial-entry.js index 8443760b..9aa631e9 100644 --- a/apps/web/src/combinatorial-entry.js +++ b/apps/web/src/combinatorial-entry.js @@ -1,11 +1,14 @@ import { PairwiseGenerator } from '../../../packages/core/js/data_generation/n-wise/pairwiseGenerator.js'; import { BachAllPairsGenerator } from '../../../packages/core/js/data_generation/n-wise/bachAllPairsGenerator.js'; import { CartesianProductGenerator } from '../../../packages/core/js/data_generation/n-wise/combinationsTestDataGenerator.js'; +import { NWiseCoverageModel } from '../../../packages/core/js/data_generation/n-wise/nWiseCoverageModel.js'; import { NWiseGenerator } from '../../../packages/core/js/data_generation/n-wise/nWiseGenerator.js'; import { EnumParser } from '../../../packages/core/js/data_generation/utils/enumParser.js'; import { isExplicitEnumRule } from '../../../packages/core/js/data_generation/utils/enum-rule-detection.js'; +import { createConfirmDialogService } from '../../../packages/core-ui/js/gui_components/shared/dialog-services/confirm-dialog-service.js'; import { CombinationAlgorithm, + calculateCartesianProductRows, COMBINATION_STRATEGIES, getAvailableStrengths, getStrategiesForStrength, @@ -27,17 +30,9 @@ const DEFAULT_SELECTED_ALGORITHMS = new Set([ CombinationAlgorithm.PICT_GCD, CombinationAlgorithm.AETG, CombinationAlgorithm.IPOG, - CombinationAlgorithm.CARTESIAN_PRODUCT, ]); -const root = document.getElementById('combinatorial-root'); -const schemaEditor = document.getElementById('combinatorial-schema'); -const strengthSelect = document.getElementById('combinatorial-strength'); -const strategiesRoot = document.getElementById('combinatorial-strategies'); -const generateButton = document.getElementById('generate-combinatorial'); -const statusElement = document.getElementById('combinatorial-status'); -const summaryRoot = document.getElementById('combinatorial-summary'); -const detailsRoot = document.getElementById('combinatorial-details'); +const CARTESIAN_CONFIRM_THRESHOLD = 10000; function escapeHtml(value) { return String(value ?? '') @@ -53,6 +48,10 @@ function normaliseAlgorithmLabel(algorithm) { return strategy?.label || algorithm; } +function formatNumber(value) { + return Number.isFinite(value) ? Number(value).toLocaleString() : '0'; +} + function parseEnumParameters(schemaText) { const contentLines = String(schemaText || '') .split(/\r?\n/) @@ -100,61 +99,93 @@ function getValueCounts(parameters) { return parameters.map((parameter) => parameter.values.length); } -function setStatus(message, severity = 'info') { - statusElement.textContent = message; - statusElement.dataset.severity = severity; +function calculateTheoreticalMinimumRows(parameters, strength) { + if (!Array.isArray(parameters) || parameters.length === 0) { + return 0; + } + const model = new NWiseCoverageModel(parameters, strength); + return model.coverageTargets.reduce((largestTupleSet, target) => Math.max(largestTupleSet, target.tuples.length), 0); } -function getSelectedStrength() { - return Number.parseInt(strengthSelect.value, 10); +function calculateRequiredTupleCount(parameters, strength) { + if (!Array.isArray(parameters) || parameters.length === 0) { + return 0; + } + const model = new NWiseCoverageModel(parameters, strength); + return model.getTotalTargetTupleCount(); } -function getSelectedAlgorithms() { - return Array.from(strategiesRoot.querySelectorAll('input[type="checkbox"]:checked')) - .map((input) => input.value) - .filter(Boolean); +function buildEstimateSummary(parameters, strength) { + const cartesianRowCount = calculateCartesianProductRows(getValueCounts(parameters)); + const theoreticalMinimumRows = + parameters.length >= strength && strength >= 1 ? calculateTheoreticalMinimumRows(parameters, strength) : 0; + const totalRequiredTuples = + parameters.length >= strength && strength >= 1 ? calculateRequiredTupleCount(parameters, strength) : 0; + + return { + cartesianRowCount, + theoreticalMinimumRows, + totalRequiredTuples, + }; } -function renderStrengthOptions() { - const { parameters } = parseEnumParameters(schemaEditor.value); - const currentStrength = getSelectedStrength(); - const strengths = getAvailableStrengths(parameters.length); - strengthSelect.innerHTML = strengths - .map((strength) => ``) - .join(''); +function sortResultsByRowCount(results) { + return [...results].sort((left, right) => { + const leftHasError = Boolean(left?.error); + const rightHasError = Boolean(right?.error); + if (leftHasError !== rightHasError) { + return leftHasError ? 1 : -1; + } - if (strengths.length > 0) { - strengthSelect.value = strengths.includes(currentStrength) ? String(currentStrength) : String(strengths[0]); - } - strengthSelect.disabled = strengths.length === 0; + const leftRows = Number.isFinite(left?.stats?.rowCount) ? left.stats.rowCount : Number.MAX_SAFE_INTEGER; + const rightRows = Number.isFinite(right?.stats?.rowCount) ? right.stats.rowCount : Number.MAX_SAFE_INTEGER; + if (leftRows !== rightRows) { + return leftRows - rightRows; + } + + const leftRuntime = Number.isFinite(left?.stats?.runtimeMs) ? left.stats.runtimeMs : Number.MAX_SAFE_INTEGER; + const rightRuntime = Number.isFinite(right?.stats?.runtimeMs) ? right.stats.runtimeMs : Number.MAX_SAFE_INTEGER; + return leftRuntime - rightRuntime; + }); } -function renderStrategyCheckboxes() { - const strength = getSelectedStrength(); - const { parameters } = parseEnumParameters(schemaEditor.value); - const availableStrategies = getStrategiesForStrength(strength, { valueCounts: getValueCounts(parameters) }); - const selectedAlgorithms = new Set(getSelectedAlgorithms()); - if (selectedAlgorithms.size === 0) { - for (const algorithm of DEFAULT_SELECTED_ALGORITHMS) { - selectedAlgorithms.add(algorithm); - } +async function filterAlgorithmsForCartesianConfirmation({ + algorithms, + parameters, + requestConfirm, + threshold = CARTESIAN_CONFIRM_THRESHOLD, +}) { + const selectedAlgorithms = Array.isArray(algorithms) ? [...algorithms] : []; + if (!selectedAlgorithms.includes(CombinationAlgorithm.CARTESIAN_PRODUCT)) { + return selectedAlgorithms; } - strategiesRoot.innerHTML = availableStrategies - .map((strategy) => { - const checked = selectedAlgorithms.has(strategy.id) ? ' checked' : ''; - return ``; - }) - .join(''); + const cartesianRowCount = calculateCartesianProductRows(getValueCounts(parameters)); + if (!Number.isFinite(cartesianRowCount) || cartesianRowCount <= threshold || typeof requestConfirm !== 'function') { + return selectedAlgorithms; + } + + const confirmed = await requestConfirm({ + title: 'Cartesian product generation', + message: `You included cartesian product generation. Are you sure? this will generate ${formatNumber(cartesianRowCount)} data rows.`, + okLabel: 'Run cartesian product', + cancelLabel: 'Skip cartesian product', + }); + + return confirmed + ? selectedAlgorithms + : selectedAlgorithms.filter((algorithm) => algorithm !== CombinationAlgorithm.CARTESIAN_PRODUCT); } -function renderControls() { - renderStrengthOptions(); - renderStrategyCheckboxes(); +function createIdleResultStats(algorithm) { + return { + algorithm, + rowCount: 0, + totalTuples: 0, + coveredTuples: 0, + coveragePercentage: 0, + runtimeMs: 0, + }; } function normalisePairwiseStats({ generator, algorithm, runtimeMs }) { @@ -214,51 +245,6 @@ function runAlgorithm({ algorithm, parameters, strength }) { }; } -function renderSummary(results, parameters, strength) { - if (results.length === 0) { - summaryRoot.innerHTML = '

No strategy results to show.

'; - return; - } - - const shape = getParameterShape(parameters); - const rows = results - .map(({ stats, error }) => { - const coverage = Number.isFinite(stats?.coveragePercentage) ? stats.coveragePercentage.toFixed(1) : '0.0'; - const runtime = Number.isFinite(stats?.runtimeMs) ? stats.runtimeMs.toFixed(2) : '0.00'; - return ` - ${escapeHtml(normaliseAlgorithmLabel(stats.algorithm))} - ${strength} - ${escapeHtml(shape)} - ${error ? 'n/a' : escapeHtml(stats.rowCount)} - ${error ? 'n/a' : escapeHtml(stats.totalTuples)} - ${error ? 'n/a' : escapeHtml(stats.coveredTuples)} - ${error ? 'n/a' : coverage} - ${error ? 'n/a' : runtime} - ${error ? escapeHtml(error) : 'OK'} - `; - }) - .join(''); - - summaryRoot.innerHTML = `
- - - - - - - - - - - - - - - ${rows} -
StrategyStrengthShapeRowsTotal required tuplesCovered tuplesCoverage %Runtime msStatus
-
`; -} - function renderRecordsTable(records) { if (!Array.isArray(records) || records.length === 0) { return '

No generated rows.

'; @@ -277,89 +263,338 @@ function renderRecordsTable(records) { `; } -function renderDetails(results) { - detailsRoot.innerHTML = results - .map(({ stats, records, error }) => { - const runtime = Number.isFinite(stats?.runtimeMs) ? stats.runtimeMs.toFixed(2) : '0.00'; - const title = `${normaliseAlgorithmLabel(stats.algorithm)} - ${error ? 'failed' : `${stats.rowCount} rows`} - ${runtime} ms`; - return `
- ${escapeHtml(title)} - ${error ? `

${escapeHtml(error)}

` : renderRecordsTable(records)} -
`; - }) - .join(''); -} +function createCombinatorialPage({ + schemaEditor, + strengthSelect, + strategiesRoot, + estimatesRoot, + generateButton, + progressElement, + statusElement, + summaryRoot, + detailsRoot, + requestConfirm, +}) { + function setStatus(message, severity = 'info') { + statusElement.textContent = message; + statusElement.dataset.severity = severity; + } -function generateCombinatorial() { - const { parameters, errors } = parseEnumParameters(schemaEditor.value); - summaryRoot.innerHTML = ''; - detailsRoot.innerHTML = ''; + function setProgress(message = '', { running = false } = {}) { + progressElement.hidden = !message; + progressElement.textContent = message; + progressElement.dataset.state = running ? 'running' : 'idle'; + } - if (errors.length > 0) { - setStatus(errors.join(' '), 'error'); - return; + function getSelectedStrength() { + return Number.parseInt(strengthSelect.value, 10); } - if (parameters.length < 2) { - setStatus('Add at least two enum fields to compare combinatorial strategies.', 'error'); - return; + + function getSelectedAlgorithms() { + return Array.from(strategiesRoot.querySelectorAll('input[type="checkbox"]:checked')) + .map((input) => input.value) + .filter(Boolean); } - renderControls(); - const strength = getSelectedStrength(); - const selectedAlgorithms = getSelectedAlgorithms(); - if (selectedAlgorithms.length === 0) { - setStatus('Select at least one strategy.', 'error'); - return; + function renderStrengthOptions() { + const { parameters } = parseEnumParameters(schemaEditor.value); + const currentStrength = getSelectedStrength(); + const strengths = getAvailableStrengths(parameters.length); + strengthSelect.innerHTML = strengths + .map((strength) => ``) + .join(''); + + if (strengths.length > 0) { + strengthSelect.value = strengths.includes(currentStrength) ? String(currentStrength) : String(strengths[0]); + } + strengthSelect.disabled = strengths.length === 0; } - setStatus(`Running ${selectedAlgorithms.length} strategies...`); - generateButton.disabled = true; + function renderEstimateSummary() { + const { parameters } = parseEnumParameters(schemaEditor.value); + const strength = getSelectedStrength(); + if (parameters.length < 2 || !Number.isInteger(strength)) { + estimatesRoot.innerHTML = + '

Add at least two enum fields to see cartesian size and n-wise coverage estimates.

'; + return; + } - const results = selectedAlgorithms.map((algorithm) => { - try { - return runAlgorithm({ algorithm, parameters, strength }); - } catch (error) { - return { - stats: { - algorithm, - rowCount: 0, - totalTuples: 0, - coveredTuples: 0, - coveragePercentage: 0, - runtimeMs: 0, - }, - records: [], - error: error.message, - }; + const summary = buildEstimateSummary(parameters, strength); + estimatesRoot.innerHTML = ` +
Cartesian product: ${escapeHtml(formatNumber(summary.cartesianRowCount))} rows
+
Theoretical minimum: at least ${escapeHtml(formatNumber(summary.theoreticalMinimumRows))} rows
+
${escapeHtml(`${strength}-wise`)} requires ${escapeHtml( + formatNumber(summary.totalRequiredTuples) + )} target tuples.
+ `; + } + + function renderStrategyCheckboxes() { + const strength = getSelectedStrength(); + const { parameters } = parseEnumParameters(schemaEditor.value); + const availableStrategies = getStrategiesForStrength(strength, { valueCounts: getValueCounts(parameters) }); + const selectedAlgorithms = new Set(getSelectedAlgorithms()); + if (selectedAlgorithms.size === 0) { + for (const algorithm of DEFAULT_SELECTED_ALGORITHMS) { + selectedAlgorithms.add(algorithm); + } } - }); - renderSummary(results, parameters, strength); - renderDetails(results); - generateButton.disabled = false; - const failedRuns = results.filter((result) => result.error).length; - if (failedRuns > 0) { - const successfulRuns = results.length - failedRuns; - const severity = successfulRuns > 0 ? 'warning' : 'error'; - setStatus( - `Completed ${results.length} strategy runs for ${strength}-wise coverage with ${failedRuns} failure${ - failedRuns === 1 ? '' : 's' - }.`, - severity - ); - return; + strategiesRoot.innerHTML = availableStrategies + .map((strategy) => { + const checked = selectedAlgorithms.has(strategy.id) ? ' checked' : ''; + return ``; + }) + .join(''); + } + + function renderControls() { + renderStrengthOptions(); + renderStrategyCheckboxes(); + renderEstimateSummary(); } - setStatus(`Completed ${results.length} strategy runs for ${strength}-wise coverage.`, 'success'); + function renderSummary(results, parameters, strength) { + if (results.length === 0) { + summaryRoot.innerHTML = '

No strategy results to show.

'; + return; + } + + const shape = getParameterShape(parameters); + const rows = sortResultsByRowCount(results) + .map(({ stats, error }) => { + const coverage = Number.isFinite(stats?.coveragePercentage) ? stats.coveragePercentage.toFixed(1) : '0.0'; + const runtime = Number.isFinite(stats?.runtimeMs) ? stats.runtimeMs.toFixed(2) : '0.00'; + return ` + ${escapeHtml(normaliseAlgorithmLabel(stats.algorithm))} + ${strength} + ${escapeHtml(shape)} + ${error ? 'n/a' : escapeHtml(stats.rowCount)} + ${error ? 'n/a' : escapeHtml(stats.totalTuples)} + ${error ? 'n/a' : escapeHtml(stats.coveredTuples)} + ${error ? 'n/a' : coverage} + ${error ? 'n/a' : runtime} + ${error ? escapeHtml(error) : 'OK'} + `; + }) + .join(''); + + summaryRoot.innerHTML = `
+ + + + + + + + + + + + + + + ${rows} +
StrategyStrengthShapeRowsTotal required tuplesCovered tuplesCoverage %Runtime msStatus
+
`; + } + + function renderDetails(results) { + detailsRoot.innerHTML = sortResultsByRowCount(results) + .map(({ stats, records, error }) => { + const runtime = Number.isFinite(stats?.runtimeMs) ? stats.runtimeMs.toFixed(2) : '0.00'; + const title = `${normaliseAlgorithmLabel(stats.algorithm)} - ${error ? 'failed' : `${stats.rowCount} rows`} - ${runtime} ms`; + return `
+ ${escapeHtml(title)} + ${error ? `

${escapeHtml(error)}

` : renderRecordsTable(records)} +
`; + }) + .join(''); + } + + async function yieldToBrowser() { + await new Promise((resolve) => { + (globalThis.requestAnimationFrame || globalThis.setTimeout)(() => resolve(), 0); + }); + } + + function syncSelectedAlgorithms(algorithms) { + const selectedSet = new Set(algorithms); + for (const input of strategiesRoot.querySelectorAll('input[type="checkbox"]')) { + input.checked = selectedSet.has(input.value); + } + } + + async function generateCombinatorial() { + const { parameters, errors } = parseEnumParameters(schemaEditor.value); + summaryRoot.innerHTML = ''; + detailsRoot.innerHTML = ''; + + if (errors.length > 0) { + setStatus(errors.join(' '), 'error'); + return; + } + if (parameters.length < 2) { + setStatus('Add at least two enum fields to compare combinatorial strategies.', 'error'); + return; + } + + renderControls(); + const strength = getSelectedStrength(); + let selectedAlgorithms = getSelectedAlgorithms(); + if (selectedAlgorithms.length === 0) { + setStatus('Select at least one strategy.', 'error'); + return; + } + + selectedAlgorithms = await filterAlgorithmsForCartesianConfirmation({ + algorithms: selectedAlgorithms, + parameters, + requestConfirm, + }); + syncSelectedAlgorithms(selectedAlgorithms); + + if (selectedAlgorithms.length === 0) { + setStatus('Cartesian product run cancelled. No strategies remain selected.', 'warning'); + setProgress(''); + return; + } + + generateButton.disabled = true; + setStatus(`Running ${selectedAlgorithms.length} strategies...`); + setProgress(`Running 0/${selectedAlgorithms.length} strategies...`, { running: true }); + + const results = []; + for (let index = 0; index < selectedAlgorithms.length; index += 1) { + const algorithm = selectedAlgorithms[index]; + const label = normaliseAlgorithmLabel(algorithm); + setProgress(`Running ${index + 1}/${selectedAlgorithms.length}: ${label}`, { running: true }); + setStatus(`Switching to ${label}...`); + await yieldToBrowser(); + + try { + results.push(runAlgorithm({ algorithm, parameters, strength })); + } catch (error) { + results.push({ + stats: createIdleResultStats(algorithm), + records: [], + error: error.message, + }); + } + + renderSummary(results, parameters, strength); + renderDetails(results); + await yieldToBrowser(); + } + + generateButton.disabled = false; + setProgress(''); + + const failedRuns = results.filter((result) => result.error).length; + if (failedRuns > 0) { + const successfulRuns = results.length - failedRuns; + const severity = successfulRuns > 0 ? 'warning' : 'error'; + setStatus( + `Completed ${results.length} strategy runs for ${strength}-wise coverage with ${failedRuns} failure${ + failedRuns === 1 ? '' : 's' + }.`, + severity + ); + return; + } + + setStatus(`Completed ${results.length} strategy runs for ${strength}-wise coverage.`, 'success'); + } + + function initialise() { + schemaEditor.value = DEFAULT_SCHEMA; + renderControls(); + setStatus('Ready.'); + setProgress(''); + schemaEditor.addEventListener('input', renderControls); + strengthSelect.addEventListener('change', () => { + renderStrategyCheckboxes(); + renderEstimateSummary(); + }); + generateButton.addEventListener('click', () => { + void generateCombinatorial(); + }); + } + + return { + initialise, + generateCombinatorial, + renderControls, + renderEstimateSummary, + setStatus, + setProgress, + }; } -schemaEditor.value = DEFAULT_SCHEMA; -renderControls(); +function initCombinatorialPage({ documentObj = globalThis.document } = {}) { + if (!documentObj) { + return null; + } + + const root = documentObj.getElementById('combinatorial-root'); + const schemaEditor = documentObj.getElementById('combinatorial-schema'); + const strengthSelect = documentObj.getElementById('combinatorial-strength'); + const strategiesRoot = documentObj.getElementById('combinatorial-strategies'); + const estimatesRoot = documentObj.getElementById('combinatorial-estimates'); + const generateButton = documentObj.getElementById('generate-combinatorial'); + const progressElement = documentObj.getElementById('combinatorial-progress'); + const statusElement = documentObj.getElementById('combinatorial-status'); + const summaryRoot = documentObj.getElementById('combinatorial-summary'); + const detailsRoot = documentObj.getElementById('combinatorial-details'); + + if ( + !root || + !schemaEditor || + !strengthSelect || + !strategiesRoot || + !estimatesRoot || + !generateButton || + !progressElement || + !statusElement || + !summaryRoot || + !detailsRoot + ) { + return null; + } -schemaEditor.addEventListener('input', renderControls); -strengthSelect.addEventListener('change', renderStrategyCheckboxes); -generateButton.addEventListener('click', generateCombinatorial); + const confirmDialogService = createConfirmDialogService({ documentObj }); + const page = createCombinatorialPage({ + root, + schemaEditor, + strengthSelect, + strategiesRoot, + estimatesRoot, + generateButton, + progressElement, + statusElement, + summaryRoot, + detailsRoot, + requestConfirm: confirmDialogService.requestConfirm, + }); + page.initialise(); + return page; +} -if (root) { - setStatus('Ready.'); +if (globalThis.document) { + initCombinatorialPage(); } + +export { + buildEstimateSummary, + calculateRequiredTupleCount, + calculateTheoreticalMinimumRows, + createCombinatorialPage, + filterAlgorithmsForCartesianConfirmation, + initCombinatorialPage, + parseEnumParameters, + sortResultsByRowCount, +}; diff --git a/apps/web/src/tests/jest/combinatorial-entry.test.js b/apps/web/src/tests/jest/combinatorial-entry.test.js new file mode 100644 index 00000000..10d9c172 --- /dev/null +++ b/apps/web/src/tests/jest/combinatorial-entry.test.js @@ -0,0 +1,108 @@ +import { jest } from '@jest/globals'; +import { JSDOM } from 'jsdom'; + +function createCombinatorialDom() { + return new JSDOM(` +
+ + +
+
+ +

+

+
+
+
+ `); +} + +describe('combinatorial entry', () => { + afterEach(() => { + jest.resetModules(); + jest.restoreAllMocks(); + delete global.document; + delete global.window; + }); + + test('buildEstimateSummary returns cartesian, lower-bound, and tuple totals', async () => { + const originalDocument = global.document; + delete global.document; + + const module = await import('../../combinatorial-entry.js'); + + const summary = module.buildEstimateSummary( + [ + { name: 'Browser', values: ['Chrome', 'Firefox', 'Safari'] }, + { name: 'OS', values: ['Windows', 'macOS', 'Linux'] }, + { name: 'Viewport', values: ['Desktop', 'Tablet', 'Mobile'] }, + { name: 'Auth', values: ['Guest', 'User', 'Admin'] }, + ], + 3 + ); + + expect(summary).toEqual({ + cartesianRowCount: 81, + theoreticalMinimumRows: 27, + totalRequiredTuples: 108, + }); + + global.document = originalDocument; + }); + + test('filterAlgorithmsForCartesianConfirmation skips cartesian when the user cancels a large run', async () => { + const originalDocument = global.document; + delete global.document; + + const module = await import('../../combinatorial-entry.js'); + const requestConfirm = jest.fn(async () => false); + + const filtered = await module.filterAlgorithmsForCartesianConfirmation({ + algorithms: ['greedy', 'cartesian-product'], + parameters: Array.from({ length: 5 }, (_, index) => ({ + name: `P${index + 1}`, + values: Array.from({ length: 10 }, (_unused, valueIndex) => `${index}-${valueIndex}`), + })), + requestConfirm, + threshold: 10000, + }); + + expect(filtered).toEqual(['greedy']); + expect(requestConfirm).toHaveBeenCalledWith({ + title: 'Cartesian product generation', + message: 'You included cartesian product generation. Are you sure? this will generate 100,000 data rows.', + okLabel: 'Run cartesian product', + cancelLabel: 'Skip cartesian product', + }); + + global.document = originalDocument; + }); + + test('initialises with cartesian product unchecked and updates estimates when n changes', async () => { + const dom = createCombinatorialDom(); + global.window = dom.window; + global.document = dom.window.document; + + await jest.isolateModulesAsync(async () => { + await import('../../combinatorial-entry.js'); + }); + + const cartesianCheckbox = global.document.querySelector('input[value="cartesian-product"]'); + const estimatesRoot = global.document.getElementById('combinatorial-estimates'); + const strengthSelect = global.document.getElementById('combinatorial-strength'); + + expect(cartesianCheckbox.checked).toBe(false); + expect(estimatesRoot.textContent).toContain('Cartesian product: 81 rows'); + expect(estimatesRoot.textContent).toContain('Theoretical minimum: at least 9 rows'); + expect(estimatesRoot.textContent).toContain('2-wise requires 54 target tuples'); + + strengthSelect.value = '3'; + strengthSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + expect(estimatesRoot.textContent).toContain('Cartesian product: 81 rows'); + expect(estimatesRoot.textContent).toContain('Theoretical minimum: at least 27 rows'); + expect(estimatesRoot.textContent).toContain('3-wise requires 108 target tuples'); + + dom.window.close(); + }); +}); diff --git a/apps/web/styles.css b/apps/web/styles.css index f2694353..4c8e5056 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -1187,6 +1187,20 @@ body.theme-dark .generator-status-text[data-severity='info'] { width: 100%; } +.combinatorial-estimates { + display: grid; + gap: 0.3rem; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background: rgba(35, 120, 77, 0.05); + line-height: 1.35; +} + +.combinatorial-estimates strong { + font-weight: 700; +} + .combinatorial-strategy-fieldset { min-width: 0; margin: 0; @@ -1232,6 +1246,26 @@ body.theme-dark .generator-status-text[data-severity='info'] { line-height: 1.4; } +.combinatorial-progress { + min-height: 1.4rem; + margin: 0; + line-height: 1.4; + color: #1f5f80; +} + +.combinatorial-progress[data-state='running']::before { + content: ''; + display: inline-block; + width: 0.8rem; + height: 0.8rem; + margin-right: 0.45rem; + border: 2px solid rgba(31, 95, 128, 0.25); + border-top-color: #1f5f80; + border-radius: 50%; + vertical-align: -0.1rem; + animation: combinatorial-spin 0.9s linear infinite; +} + .combinatorial-status[data-severity='error'], .combinatorial-error { color: #9c1f1f; @@ -1297,6 +1331,12 @@ body.theme-dark .generator-status-text[data-severity='info'] { margin: 0.75rem; } +@keyframes combinatorial-spin { + to { + transform: rotate(360deg); + } +} + @media (max-width: 820px) { .combinatorial-workspace { grid-template-columns: 1fr; From 87dfe6d00bcd107c124dbb1bd29927eec83e2c4b Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Tue, 9 Jun 2026 13:25:55 +0100 Subject: [PATCH 2/6] Fix combinatorial schema layout overflow --- apps/web/styles.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/styles.css b/apps/web/styles.css index 4c8e5056..b046dbf9 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -1159,6 +1159,7 @@ body.theme-dark .generator-status-text[data-severity='info'] { .combinatorial-results { display: grid; gap: 0.75rem; + min-width: 0; } .combinatorial-label, @@ -1168,9 +1169,12 @@ body.theme-dark .generator-status-text[data-severity='info'] { .combinatorial-schema-editor { width: 100%; + max-width: 100%; + min-width: 0; min-height: 24rem; resize: vertical; padding: 0.75rem; + box-sizing: border-box; border: 1px solid var(--border-color); border-radius: 8px; background: var(--panel-bg); From a4bee9c1cd3352fe58ec0516dcb308b3f615418a Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Tue, 9 Jun 2026 13:48:18 +0100 Subject: [PATCH 3/6] Address combinatorial review fixes --- apps/web/src/combinatorial-entry.js | 112 +++++++++++------- .../tests/jest/combinatorial-entry.test.js | 30 ++--- 2 files changed, 79 insertions(+), 63 deletions(-) diff --git a/apps/web/src/combinatorial-entry.js b/apps/web/src/combinatorial-entry.js index 9aa631e9..0811104e 100644 --- a/apps/web/src/combinatorial-entry.js +++ b/apps/web/src/combinatorial-entry.js @@ -49,7 +49,13 @@ function normaliseAlgorithmLabel(algorithm) { } function formatNumber(value) { - return Number.isFinite(value) ? Number(value).toLocaleString() : '0'; + if (value === Number.POSITIVE_INFINITY) { + return 'Too large to estimate'; + } + if (value === Number.NEGATIVE_INFINITY) { + return 'Too small to estimate'; + } + return Number.isFinite(value) ? Number(value).toLocaleString('en-US') : 'Unknown'; } function parseEnumParameters(schemaText) { @@ -115,12 +121,30 @@ function calculateRequiredTupleCount(parameters, strength) { return model.getTotalTargetTupleCount(); } +function calculateEstimateDetails(parameters, strength) { + if (!Array.isArray(parameters) || parameters.length === 0) { + return { + theoreticalMinimumRows: 0, + totalRequiredTuples: 0, + }; + } + + const model = new NWiseCoverageModel(parameters, strength); + return { + theoreticalMinimumRows: model.coverageTargets.reduce( + (largestTupleSet, target) => Math.max(largestTupleSet, target.tuples.length), + 0 + ), + totalRequiredTuples: model.getTotalTargetTupleCount(), + }; +} + function buildEstimateSummary(parameters, strength) { const cartesianRowCount = calculateCartesianProductRows(getValueCounts(parameters)); - const theoreticalMinimumRows = - parameters.length >= strength && strength >= 1 ? calculateTheoreticalMinimumRows(parameters, strength) : 0; - const totalRequiredTuples = - parameters.length >= strength && strength >= 1 ? calculateRequiredTupleCount(parameters, strength) : 0; + const { theoreticalMinimumRows, totalRequiredTuples } = + parameters.length >= strength && strength >= 1 + ? calculateEstimateDetails(parameters, strength) + : { theoreticalMinimumRows: 0, totalRequiredTuples: 0 }; return { cartesianRowCount, @@ -161,7 +185,10 @@ async function filterAlgorithmsForCartesianConfirmation({ } const cartesianRowCount = calculateCartesianProductRows(getValueCounts(parameters)); - if (!Number.isFinite(cartesianRowCount) || cartesianRowCount <= threshold || typeof requestConfirm !== 'function') { + if (typeof requestConfirm !== 'function') { + return selectedAlgorithms; + } + if (Number.isFinite(cartesianRowCount) && cartesianRowCount <= threshold) { return selectedAlgorithms; } @@ -469,45 +496,47 @@ function createCombinatorialPage({ setProgress(`Running 0/${selectedAlgorithms.length} strategies...`, { running: true }); const results = []; - for (let index = 0; index < selectedAlgorithms.length; index += 1) { - const algorithm = selectedAlgorithms[index]; - const label = normaliseAlgorithmLabel(algorithm); - setProgress(`Running ${index + 1}/${selectedAlgorithms.length}: ${label}`, { running: true }); - setStatus(`Switching to ${label}...`); - await yieldToBrowser(); - - try { - results.push(runAlgorithm({ algorithm, parameters, strength })); - } catch (error) { - results.push({ - stats: createIdleResultStats(algorithm), - records: [], - error: error.message, - }); + try { + for (let index = 0; index < selectedAlgorithms.length; index += 1) { + const algorithm = selectedAlgorithms[index]; + const label = normaliseAlgorithmLabel(algorithm); + setProgress(`Running ${index + 1}/${selectedAlgorithms.length}: ${label}`, { running: true }); + setStatus(`Switching to ${label}...`); + await yieldToBrowser(); + + try { + results.push(runAlgorithm({ algorithm, parameters, strength })); + } catch (error) { + results.push({ + stats: createIdleResultStats(algorithm), + records: [], + error: error.message, + }); + } + + renderSummary(results, parameters, strength); + renderDetails(results); + await yieldToBrowser(); } - renderSummary(results, parameters, strength); - renderDetails(results); - await yieldToBrowser(); - } - - generateButton.disabled = false; - setProgress(''); + const failedRuns = results.filter((result) => result.error).length; + if (failedRuns > 0) { + const successfulRuns = results.length - failedRuns; + const severity = successfulRuns > 0 ? 'warning' : 'error'; + setStatus( + `Completed ${results.length} strategy runs for ${strength}-wise coverage with ${failedRuns} failure${ + failedRuns === 1 ? '' : 's' + }.`, + severity + ); + return; + } - const failedRuns = results.filter((result) => result.error).length; - if (failedRuns > 0) { - const successfulRuns = results.length - failedRuns; - const severity = successfulRuns > 0 ? 'warning' : 'error'; - setStatus( - `Completed ${results.length} strategy runs for ${strength}-wise coverage with ${failedRuns} failure${ - failedRuns === 1 ? '' : 's' - }.`, - severity - ); - return; + setStatus(`Completed ${results.length} strategy runs for ${strength}-wise coverage.`, 'success'); + } finally { + generateButton.disabled = false; + setProgress(''); } - - setStatus(`Completed ${results.length} strategy runs for ${strength}-wise coverage.`, 'success'); } function initialise() { @@ -568,7 +597,6 @@ function initCombinatorialPage({ documentObj = globalThis.document } = {}) { const confirmDialogService = createConfirmDialogService({ documentObj }); const page = createCombinatorialPage({ - root, schemaEditor, strengthSelect, strategiesRoot, diff --git a/apps/web/src/tests/jest/combinatorial-entry.test.js b/apps/web/src/tests/jest/combinatorial-entry.test.js index 10d9c172..b70ca1e1 100644 --- a/apps/web/src/tests/jest/combinatorial-entry.test.js +++ b/apps/web/src/tests/jest/combinatorial-entry.test.js @@ -1,5 +1,10 @@ import { jest } from '@jest/globals'; import { JSDOM } from 'jsdom'; +import { + buildEstimateSummary, + filterAlgorithmsForCartesianConfirmation, + initCombinatorialPage, +} from '../../combinatorial-entry.js'; function createCombinatorialDom() { return new JSDOM(` @@ -19,19 +24,13 @@ function createCombinatorialDom() { describe('combinatorial entry', () => { afterEach(() => { - jest.resetModules(); jest.restoreAllMocks(); delete global.document; delete global.window; }); - test('buildEstimateSummary returns cartesian, lower-bound, and tuple totals', async () => { - const originalDocument = global.document; - delete global.document; - - const module = await import('../../combinatorial-entry.js'); - - const summary = module.buildEstimateSummary( + test('buildEstimateSummary returns cartesian, lower-bound, and tuple totals', () => { + const summary = buildEstimateSummary( [ { name: 'Browser', values: ['Chrome', 'Firefox', 'Safari'] }, { name: 'OS', values: ['Windows', 'macOS', 'Linux'] }, @@ -46,18 +45,12 @@ describe('combinatorial entry', () => { theoreticalMinimumRows: 27, totalRequiredTuples: 108, }); - - global.document = originalDocument; }); test('filterAlgorithmsForCartesianConfirmation skips cartesian when the user cancels a large run', async () => { - const originalDocument = global.document; - delete global.document; - - const module = await import('../../combinatorial-entry.js'); const requestConfirm = jest.fn(async () => false); - const filtered = await module.filterAlgorithmsForCartesianConfirmation({ + const filtered = await filterAlgorithmsForCartesianConfirmation({ algorithms: ['greedy', 'cartesian-product'], parameters: Array.from({ length: 5 }, (_, index) => ({ name: `P${index + 1}`, @@ -74,18 +67,13 @@ describe('combinatorial entry', () => { okLabel: 'Run cartesian product', cancelLabel: 'Skip cartesian product', }); - - global.document = originalDocument; }); test('initialises with cartesian product unchecked and updates estimates when n changes', async () => { const dom = createCombinatorialDom(); global.window = dom.window; global.document = dom.window.document; - - await jest.isolateModulesAsync(async () => { - await import('../../combinatorial-entry.js'); - }); + initCombinatorialPage({ documentObj: global.document }); const cartesianCheckbox = global.document.querySelector('input[value="cartesian-product"]'); const estimatesRoot = global.document.getElementById('combinatorial-estimates'); From df890ee02e9945f5211fe91ff46581a0118b0fac Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Tue, 9 Jun 2026 14:05:25 +0100 Subject: [PATCH 4/6] Polish combinatorial progress styles --- .../src/tests/jest/combinatorial-entry.test.js | 12 ++++++------ apps/web/styles.css | 17 ++++++++++++++--- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/web/src/tests/jest/combinatorial-entry.test.js b/apps/web/src/tests/jest/combinatorial-entry.test.js index b70ca1e1..4b19a35d 100644 --- a/apps/web/src/tests/jest/combinatorial-entry.test.js +++ b/apps/web/src/tests/jest/combinatorial-entry.test.js @@ -61,12 +61,12 @@ describe('combinatorial entry', () => { }); expect(filtered).toEqual(['greedy']); - expect(requestConfirm).toHaveBeenCalledWith({ - title: 'Cartesian product generation', - message: 'You included cartesian product generation. Are you sure? this will generate 100,000 data rows.', - okLabel: 'Run cartesian product', - cancelLabel: 'Skip cartesian product', - }); + expect(requestConfirm).toHaveBeenCalledTimes(1); + const [confirmOptions] = requestConfirm.mock.calls[0]; + expect(confirmOptions.title).toBe('Cartesian product generation'); + expect(confirmOptions.okLabel).toBe('Run cartesian product'); + expect(confirmOptions.cancelLabel).toBe('Skip cartesian product'); + expect(confirmOptions.message).toContain('100,000'); }); test('initialises with cartesian product unchecked and updates estimates when n changes', async () => { diff --git a/apps/web/styles.css b/apps/web/styles.css index b046dbf9..eb66cc66 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -26,6 +26,8 @@ Light to dark grey green: F6FFF8, EAF4F4, CCE3DE, A4C3B2, 2D6A4F, 6B9080, 3D5249 --button-bg: #2d6a4f; --button-text: #ffffff; --border-color: #a9d6e5; + --progress-info-color: #1f5f80; + --progress-info-border-color: rgba(31, 95, 128, 0.25); } body.theme-dark { @@ -37,6 +39,8 @@ body.theme-dark { --button-bg: #1f9f84; --button-text: #ffffff; --border-color: #3a3b3c; + --progress-info-color: #89c2d9; + --progress-info-border-color: rgba(137, 194, 217, 0.35); } body { @@ -1254,7 +1258,7 @@ body.theme-dark .generator-status-text[data-severity='info'] { min-height: 1.4rem; margin: 0; line-height: 1.4; - color: #1f5f80; + color: var(--progress-info-color); } .combinatorial-progress[data-state='running']::before { @@ -1263,8 +1267,8 @@ body.theme-dark .generator-status-text[data-severity='info'] { width: 0.8rem; height: 0.8rem; margin-right: 0.45rem; - border: 2px solid rgba(31, 95, 128, 0.25); - border-top-color: #1f5f80; + border: 2px solid var(--progress-info-border-color); + border-top-color: var(--progress-info-color); border-radius: 50%; vertical-align: -0.1rem; animation: combinatorial-spin 0.9s linear infinite; @@ -1341,6 +1345,13 @@ body.theme-dark .generator-status-text[data-severity='info'] { } } +@media (prefers-reduced-motion: reduce) { + .combinatorial-progress[data-state='running']::before { + animation: none; + transform: none; + } +} + @media (max-width: 820px) { .combinatorial-workspace { grid-template-columns: 1fr; From 58076d1e30beda2b323adc24e30eddba8c08d75d Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Tue, 9 Jun 2026 14:18:27 +0100 Subject: [PATCH 5/6] Fix combinatorial Jest ESM boundary --- apps/web/src/package.json | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 apps/web/src/package.json diff --git a/apps/web/src/package.json b/apps/web/src/package.json new file mode 100644 index 00000000..3dbc1ca5 --- /dev/null +++ b/apps/web/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} From 019f12e1480e7eca6433405b42ebb0c4643a1990 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Tue, 9 Jun 2026 14:52:28 +0100 Subject: [PATCH 6/6] Scope combinatorial entry ESM fix --- apps/web/combinatorial.html | 2 +- .../src/{combinatorial-entry.js => combinatorial-entry.mjs} | 0 apps/web/src/package.json | 3 --- apps/web/src/tests/jest/combinatorial-entry.test.js | 2 +- 4 files changed, 2 insertions(+), 5 deletions(-) rename apps/web/src/{combinatorial-entry.js => combinatorial-entry.mjs} (100%) delete mode 100644 apps/web/src/package.json diff --git a/apps/web/combinatorial.html b/apps/web/combinatorial.html index a1fff1e3..31ea6edf 100644 --- a/apps/web/combinatorial.html +++ b/apps/web/combinatorial.html @@ -9,7 +9,7 @@ - + diff --git a/apps/web/src/combinatorial-entry.js b/apps/web/src/combinatorial-entry.mjs similarity index 100% rename from apps/web/src/combinatorial-entry.js rename to apps/web/src/combinatorial-entry.mjs diff --git a/apps/web/src/package.json b/apps/web/src/package.json deleted file mode 100644 index 3dbc1ca5..00000000 --- a/apps/web/src/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/apps/web/src/tests/jest/combinatorial-entry.test.js b/apps/web/src/tests/jest/combinatorial-entry.test.js index 4b19a35d..a0da61a3 100644 --- a/apps/web/src/tests/jest/combinatorial-entry.test.js +++ b/apps/web/src/tests/jest/combinatorial-entry.test.js @@ -4,7 +4,7 @@ import { buildEstimateSummary, filterAlgorithmsForCartesianConfirmation, initCombinatorialPage, -} from '../../combinatorial-entry.js'; +} from '../../combinatorial-entry.mjs'; function createCombinatorialDom() { return new JSDOM(`