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
+
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 = `
-
-
-
- | Strategy |
- Strength |
- Shape |
- Rows |
- Total required tuples |
- Covered tuples |
- Coverage % |
- Runtime ms |
- Status |
-
-
- ${rows}
-
-
`;
-}
-
-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 = `
+
+
+
+ | Strategy |
+ Strength |
+ Shape |
+ Rows |
+ Total required tuples |
+ Covered tuples |
+ Coverage % |
+ Runtime ms |
+ Status |
+
+
+ ${rows}
+
+
`;
+ }
+
+ 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;