diff --git a/apps/web/combinatorial.html b/apps/web/combinatorial.html index 4e037d28..31ea6edf 100644 --- a/apps/web/combinatorial.html +++ b/apps/web/combinatorial.html @@ -9,7 +9,7 @@ - + @@ -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 deleted file mode 100644 index 8443760b..00000000 --- a/apps/web/src/combinatorial-entry.js +++ /dev/null @@ -1,365 +0,0 @@ -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 { 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 { - CombinationAlgorithm, - COMBINATION_STRATEGIES, - getAvailableStrengths, - getStrategiesForStrength, -} from '../../../packages/core-ui/js/gui_components/generator/generation/n-wise-generation-options.js'; - -const DEFAULT_SCHEMA = `Browser -enum("Chrome","Firefox","Safari") -OS -enum("Windows","macOS","Linux") -Viewport -enum("Desktop","Tablet","Mobile") -Auth -enum("Guest","User","Admin")`; - -const DEFAULT_SELECTED_ALGORITHMS = new Set([ - CombinationAlgorithm.BACH_ALLPAIRS, - CombinationAlgorithm.PAIRWISE, - CombinationAlgorithm.GREEDY, - 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'); - -function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function normaliseAlgorithmLabel(algorithm) { - const strategy = COMBINATION_STRATEGIES.find((item) => item.id === algorithm); - return strategy?.label || algorithm; -} - -function parseEnumParameters(schemaText) { - const contentLines = String(schemaText || '') - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith('#')); - const parameters = []; - const errors = []; - - for (let index = 0; index < contentLines.length; index += 2) { - const name = contentLines[index]; - const ruleSpec = contentLines[index + 1]; - if (!name || !ruleSpec) { - errors.push('Schema must use pairs of lines: column name followed by generation rule.'); - break; - } - if (!isExplicitEnumRule(ruleSpec)) { - continue; - } - try { - const values = EnumParser.extractEnumValues(ruleSpec).filter((value) => String(value).length > 0); - if (values.length > 0) { - parameters.push({ name, values }); - } - } catch (error) { - errors.push(`${name}: ${error.message}`); - } - } - - const names = new Set(); - for (const parameter of parameters) { - if (names.has(parameter.name)) { - errors.push(`Enum parameter names must be unique: ${parameter.name}`); - } - names.add(parameter.name); - } - - return { parameters, errors }; -} - -function getParameterShape(parameters) { - return `${parameters.length} parameters x ${parameters.map((parameter) => parameter.values.length).join('/')}`; -} - -function getValueCounts(parameters) { - return parameters.map((parameter) => parameter.values.length); -} - -function setStatus(message, severity = 'info') { - statusElement.textContent = message; - statusElement.dataset.severity = severity; -} - -function getSelectedStrength() { - return Number.parseInt(strengthSelect.value, 10); -} - -function getSelectedAlgorithms() { - return Array.from(strategiesRoot.querySelectorAll('input[type="checkbox"]:checked')) - .map((input) => input.value) - .filter(Boolean); -} - -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; -} - -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); - } - } - - strategiesRoot.innerHTML = availableStrategies - .map((strategy) => { - const checked = selectedAlgorithms.has(strategy.id) ? ' checked' : ''; - return ``; - }) - .join(''); -} - -function renderControls() { - renderStrengthOptions(); - renderStrategyCheckboxes(); -} - -function normalisePairwiseStats({ generator, algorithm, runtimeMs }) { - const stats = generator.getCoverageStats(); - return { - algorithm, - rowCount: stats.totalRecords, - totalTuples: stats.totalPairs, - coveredTuples: stats.coveredPairs, - coveragePercentage: stats.coveragePercentage, - runtimeMs, - }; -} - -function runAlgorithm({ algorithm, parameters, strength }) { - const startedAt = performance.now(); - if ([CombinationAlgorithm.PAIRWISE, CombinationAlgorithm.BACH_ALLPAIRS].includes(algorithm)) { - if (strength !== 2) { - throw new Error('Pairwise algorithms only support strength 2.'); - } - const generator = - algorithm === CombinationAlgorithm.BACH_ALLPAIRS - ? new BachAllPairsGenerator(parameters, { seed: 1 }) - : new PairwiseGenerator(parameters); - generator.generateDataSet(); - const runtimeMs = performance.now() - startedAt; - return { - stats: normalisePairwiseStats({ generator, algorithm, runtimeMs }), - records: generator.exportDataRecords(), - }; - } - - if (algorithm === CombinationAlgorithm.CARTESIAN_PRODUCT) { - const generator = new CartesianProductGenerator(parameters, { strength }); - generator.generateDataSet(); - const runtimeMs = performance.now() - startedAt; - return { - stats: { - ...generator.getBenchmarkStats(), - runtimeMs, - }, - records: generator.exportDataRecords(), - }; - } - - const generator = new NWiseGenerator(parameters, { - strength, - algorithm, - seed: 1, - candidateCount: 20, - runs: algorithm === CombinationAlgorithm.AETG ? 3 : 1, - }); - generator.generateDataSet(); - return { - stats: generator.getBenchmarkStats(), - records: generator.exportDataRecords(), - }; -} - -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.

'; - } - const headers = Object.keys(records[0]); - const headerCells = headers.map((header) => `${escapeHtml(header)}`).join(''); - const bodyRows = records - .map((record) => `${headers.map((header) => `${escapeHtml(record[header])}`).join('')}`) - .join(''); - - return `
- - ${headerCells} - ${bodyRows} -
-
`; -} - -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 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(); - const selectedAlgorithms = getSelectedAlgorithms(); - if (selectedAlgorithms.length === 0) { - setStatus('Select at least one strategy.', 'error'); - return; - } - - setStatus(`Running ${selectedAlgorithms.length} strategies...`); - generateButton.disabled = true; - - 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, - }; - } - }); - - 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; - } - - setStatus(`Completed ${results.length} strategy runs for ${strength}-wise coverage.`, 'success'); -} - -schemaEditor.value = DEFAULT_SCHEMA; -renderControls(); - -schemaEditor.addEventListener('input', renderControls); -strengthSelect.addEventListener('change', renderStrategyCheckboxes); -generateButton.addEventListener('click', generateCombinatorial); - -if (root) { - setStatus('Ready.'); -} diff --git a/apps/web/src/combinatorial-entry.mjs b/apps/web/src/combinatorial-entry.mjs new file mode 100644 index 00000000..0811104e --- /dev/null +++ b/apps/web/src/combinatorial-entry.mjs @@ -0,0 +1,628 @@ +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, +} from '../../../packages/core-ui/js/gui_components/generator/generation/n-wise-generation-options.js'; + +const DEFAULT_SCHEMA = `Browser +enum("Chrome","Firefox","Safari") +OS +enum("Windows","macOS","Linux") +Viewport +enum("Desktop","Tablet","Mobile") +Auth +enum("Guest","User","Admin")`; + +const DEFAULT_SELECTED_ALGORITHMS = new Set([ + CombinationAlgorithm.BACH_ALLPAIRS, + CombinationAlgorithm.PAIRWISE, + CombinationAlgorithm.GREEDY, + CombinationAlgorithm.PICT_GCD, + CombinationAlgorithm.AETG, + CombinationAlgorithm.IPOG, +]); + +const CARTESIAN_CONFIRM_THRESHOLD = 10000; + +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function normaliseAlgorithmLabel(algorithm) { + const strategy = COMBINATION_STRATEGIES.find((item) => item.id === algorithm); + return strategy?.label || algorithm; +} + +function formatNumber(value) { + 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) { + const contentLines = String(schemaText || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')); + const parameters = []; + const errors = []; + + for (let index = 0; index < contentLines.length; index += 2) { + const name = contentLines[index]; + const ruleSpec = contentLines[index + 1]; + if (!name || !ruleSpec) { + errors.push('Schema must use pairs of lines: column name followed by generation rule.'); + break; + } + if (!isExplicitEnumRule(ruleSpec)) { + continue; + } + try { + const values = EnumParser.extractEnumValues(ruleSpec).filter((value) => String(value).length > 0); + if (values.length > 0) { + parameters.push({ name, values }); + } + } catch (error) { + errors.push(`${name}: ${error.message}`); + } + } + + const names = new Set(); + for (const parameter of parameters) { + if (names.has(parameter.name)) { + errors.push(`Enum parameter names must be unique: ${parameter.name}`); + } + names.add(parameter.name); + } + + return { parameters, errors }; +} + +function getParameterShape(parameters) { + return `${parameters.length} parameters x ${parameters.map((parameter) => parameter.values.length).join('/')}`; +} + +function getValueCounts(parameters) { + return parameters.map((parameter) => parameter.values.length); +} + +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 calculateRequiredTupleCount(parameters, strength) { + if (!Array.isArray(parameters) || parameters.length === 0) { + return 0; + } + const model = new NWiseCoverageModel(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, totalRequiredTuples } = + parameters.length >= strength && strength >= 1 + ? calculateEstimateDetails(parameters, strength) + : { theoreticalMinimumRows: 0, totalRequiredTuples: 0 }; + + return { + cartesianRowCount, + theoreticalMinimumRows, + totalRequiredTuples, + }; +} + +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; + } + + 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; + }); +} + +async function filterAlgorithmsForCartesianConfirmation({ + algorithms, + parameters, + requestConfirm, + threshold = CARTESIAN_CONFIRM_THRESHOLD, +}) { + const selectedAlgorithms = Array.isArray(algorithms) ? [...algorithms] : []; + if (!selectedAlgorithms.includes(CombinationAlgorithm.CARTESIAN_PRODUCT)) { + return selectedAlgorithms; + } + + const cartesianRowCount = calculateCartesianProductRows(getValueCounts(parameters)); + if (typeof requestConfirm !== 'function') { + return selectedAlgorithms; + } + if (Number.isFinite(cartesianRowCount) && cartesianRowCount <= threshold) { + 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 createIdleResultStats(algorithm) { + return { + algorithm, + rowCount: 0, + totalTuples: 0, + coveredTuples: 0, + coveragePercentage: 0, + runtimeMs: 0, + }; +} + +function normalisePairwiseStats({ generator, algorithm, runtimeMs }) { + const stats = generator.getCoverageStats(); + return { + algorithm, + rowCount: stats.totalRecords, + totalTuples: stats.totalPairs, + coveredTuples: stats.coveredPairs, + coveragePercentage: stats.coveragePercentage, + runtimeMs, + }; +} + +function runAlgorithm({ algorithm, parameters, strength }) { + const startedAt = performance.now(); + if ([CombinationAlgorithm.PAIRWISE, CombinationAlgorithm.BACH_ALLPAIRS].includes(algorithm)) { + if (strength !== 2) { + throw new Error('Pairwise algorithms only support strength 2.'); + } + const generator = + algorithm === CombinationAlgorithm.BACH_ALLPAIRS + ? new BachAllPairsGenerator(parameters, { seed: 1 }) + : new PairwiseGenerator(parameters); + generator.generateDataSet(); + const runtimeMs = performance.now() - startedAt; + return { + stats: normalisePairwiseStats({ generator, algorithm, runtimeMs }), + records: generator.exportDataRecords(), + }; + } + + if (algorithm === CombinationAlgorithm.CARTESIAN_PRODUCT) { + const generator = new CartesianProductGenerator(parameters, { strength }); + generator.generateDataSet(); + const runtimeMs = performance.now() - startedAt; + return { + stats: { + ...generator.getBenchmarkStats(), + runtimeMs, + }, + records: generator.exportDataRecords(), + }; + } + + const generator = new NWiseGenerator(parameters, { + strength, + algorithm, + seed: 1, + candidateCount: 20, + runs: algorithm === CombinationAlgorithm.AETG ? 3 : 1, + }); + generator.generateDataSet(); + return { + stats: generator.getBenchmarkStats(), + records: generator.exportDataRecords(), + }; +} + +function renderRecordsTable(records) { + if (!Array.isArray(records) || records.length === 0) { + return '

No generated rows.

'; + } + const headers = Object.keys(records[0]); + const headerCells = headers.map((header) => `${escapeHtml(header)}`).join(''); + const bodyRows = records + .map((record) => `${headers.map((header) => `${escapeHtml(record[header])}`).join('')}`) + .join(''); + + return `
+ + ${headerCells} + ${bodyRows} +
+
`; +} + +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 setProgress(message = '', { running = false } = {}) { + progressElement.hidden = !message; + progressElement.textContent = message; + progressElement.dataset.state = running ? 'running' : 'idle'; + } + + function getSelectedStrength() { + return Number.parseInt(strengthSelect.value, 10); + } + + function getSelectedAlgorithms() { + return Array.from(strategiesRoot.querySelectorAll('input[type="checkbox"]:checked')) + .map((input) => input.value) + .filter(Boolean); + } + + 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; + } + + 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 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); + } + } + + strategiesRoot.innerHTML = availableStrategies + .map((strategy) => { + const checked = selectedAlgorithms.has(strategy.id) ? ' checked' : ''; + return ``; + }) + .join(''); + } + + function renderControls() { + renderStrengthOptions(); + renderStrategyCheckboxes(); + renderEstimateSummary(); + } + + 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 = []; + 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(); + } + + 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(''); + } + } + + 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, + }; +} + +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; + } + + const confirmDialogService = createConfirmDialogService({ documentObj }); + const page = createCombinatorialPage({ + schemaEditor, + strengthSelect, + strategiesRoot, + estimatesRoot, + generateButton, + progressElement, + statusElement, + summaryRoot, + detailsRoot, + requestConfirm: confirmDialogService.requestConfirm, + }); + page.initialise(); + return page; +} + +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..a0da61a3 --- /dev/null +++ b/apps/web/src/tests/jest/combinatorial-entry.test.js @@ -0,0 +1,96 @@ +import { jest } from '@jest/globals'; +import { JSDOM } from 'jsdom'; +import { + buildEstimateSummary, + filterAlgorithmsForCartesianConfirmation, + initCombinatorialPage, +} from '../../combinatorial-entry.mjs'; + +function createCombinatorialDom() { + return new JSDOM(` +
+ + +
+
+ +

+

+
+
+
+ `); +} + +describe('combinatorial entry', () => { + afterEach(() => { + jest.restoreAllMocks(); + delete global.document; + delete global.window; + }); + + test('buildEstimateSummary returns cartesian, lower-bound, and tuple totals', () => { + const summary = 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, + }); + }); + + test('filterAlgorithmsForCartesianConfirmation skips cartesian when the user cancels a large run', async () => { + const requestConfirm = jest.fn(async () => false); + + const filtered = await 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).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 () => { + const dom = createCombinatorialDom(); + global.window = dom.window; + global.document = dom.window.document; + initCombinatorialPage({ documentObj: global.document }); + + 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..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 { @@ -1159,6 +1163,7 @@ body.theme-dark .generator-status-text[data-severity='info'] { .combinatorial-results { display: grid; gap: 0.75rem; + min-width: 0; } .combinatorial-label, @@ -1168,9 +1173,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); @@ -1187,6 +1195,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 +1254,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: var(--progress-info-color); +} + +.combinatorial-progress[data-state='running']::before { + content: ''; + display: inline-block; + width: 0.8rem; + height: 0.8rem; + margin-right: 0.45rem; + 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; +} + .combinatorial-status[data-severity='error'], .combinatorial-error { color: #9c1f1f; @@ -1297,6 +1339,19 @@ body.theme-dark .generator-status-text[data-severity='info'] { margin: 0.75rem; } +@keyframes combinatorial-spin { + to { + transform: rotate(360deg); + } +} + +@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;