diff --git a/apps/web/src/stories/shared-schema-definition.stories.js b/apps/web/src/stories/shared-schema-definition.stories.js
index 687c022f..0cadd35d 100644
--- a/apps/web/src/stories/shared-schema-definition.stories.js
+++ b/apps/web/src/stories/shared-schema-definition.stories.js
@@ -541,3 +541,55 @@ export const ParamsDialog = {
);
},
};
+
+export const EnumParamsDialog = {
+ render: renderSharedSchemaDefinitionStory,
+ args: {
+ storyMinHeight: '820px',
+ initialRows: [
+ {
+ id: 'country-code-row',
+ name: 'Country Code',
+ sourceType: 'domain',
+ command: 'location.countryCode',
+ params: '',
+ value: '',
+ comments: '',
+ leadingTextLines: [],
+ },
+ ],
+ },
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ story:
+ 'Enum params editing flow. This story demonstrates explicit `type: "enum"` metadata as a dropdown: `location.countryCode` exposes `variant` choices from `enumValues`, and applying a selection writes the generated named params back into the schema row without free-text entry.',
+ },
+ },
+ },
+ play: async ({ canvasElement }) => {
+ expectSchemaModeVisible(canvasElement);
+ const initialRow = canvasElement.querySelector('.shared-schema-row');
+ const paramsButton = initialRow.querySelector('[data-action="edit-params"]');
+ expect(paramsButton).not.toBeNull();
+ await userEvent.click(paramsButton);
+
+ const dialog = within(document.body).getByRole('dialog', { name: /edit params for .*location\.countrycode/i });
+ const dialogScope = within(dialog);
+ const variantSelect = dialogScope.getByRole('combobox', { name: /variant value/i });
+ await expect(variantSelect).toBeVisible();
+ await expect(dialogScope.queryByRole('textbox', { name: /variant value/i })).toBeNull();
+ await userEvent.selectOptions(variantSelect, 'alpha-3');
+ await waitFor(() =>
+ expect(dialog.querySelector('[data-role="params-editor-preview"]')?.textContent || '').toContain(
+ 'variant="alpha-3"'
+ )
+ );
+ await userEvent.click(dialogScope.getByRole('button', { name: /^apply$/i }));
+
+ await waitFor(() =>
+ expect(canvasElement.querySelector('.shared-schema-row [data-field="params"]').value).toBe('(variant="alpha-3")')
+ );
+ },
+};
diff --git a/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js b/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js
index 0c678ea8..0d9dcf58 100644
--- a/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js
+++ b/apps/web/src/tests/browser/generator/functional/schema-edit.spec.js
@@ -326,11 +326,11 @@ test.describe('Generator Schema Editing', () => {
test('invalid enum text shows a schema error when previewing generator data', async ({ page }) => {
const { generatorPage, pageErrors } = await openGenerator(page);
- await generatorPage.schema.setSchemaText('Status\ndatatype.enum(values="")');
+ await generatorPage.schema.setSchemaText('Status\ndatatype.enum(csv="active,,pending")');
await generatorPage.preview.clickPreview();
await expect(generatorPage.schema.errorStatus).toContainText(
- 'Status failed domain validation - Invalid keyword arguments: argument "values" is required'
+ 'Status failed domain validation - Invalid keyword arguments: enum values cannot be empty'
);
await expect.poll(async () => generatorPage.preview.getOutputPreviewText()).toBe('');
@@ -476,6 +476,27 @@ test.describe('Generator Schema Editing', () => {
expectNoPageErrors(pageErrors);
});
+ test('enum command params can be selected through the guided params dialog', async ({ page }) => {
+ const { generatorPage, pageErrors } = await openGenerator(page);
+
+ await generatorPage.schema.setTextMode(false);
+ await generatorPage.schema.setRowName(0, 'Country Code');
+ await generatorPage.schema.editor.setRowTypeValue(0, 'location.countryCode');
+ await generatorPage.schema.editor.editRowEnumParamsWithDialog(0, {
+ variant: 'alpha-3',
+ });
+
+ await expect(generatorPage.schema.row(0).locator('[data-action="pick-command"]')).toHaveText(
+ 'location.countryCode'
+ );
+ await expect(generatorPage.schema.row(0).locator('input[data-field="params"]')).toHaveValue('(variant="alpha-3")');
+ await expect
+ .poll(async () => generatorPage.schema.getSchemaText())
+ .toContain('location.countryCode(variant="alpha-3")');
+
+ expectNoPageErrors(pageErrors);
+ });
+
test('schema edit buttons states are correct across top middle and bottom rows', async ({ page }) => {
const { generatorPage, pageErrors } = await openGenerator(page);
diff --git a/apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js b/apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js
index 95896d1c..44a109ed 100644
--- a/apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js
+++ b/apps/web/src/tests/browser/shared/abstractions/components/params-editor-dialog.component.js
@@ -20,11 +20,21 @@ class ParamsEditorDialogComponent {
return this.dialog.getByRole('textbox', { name: new RegExp(`^${escapeRegExp(name)} value$`, 'i') });
}
+ enumSelect(name) {
+ return this.dialog.getByRole('combobox', { name: new RegExp(`^${escapeRegExp(name)} value$`, 'i') });
+ }
+
async setValue(name, value) {
await this.expectOpen();
await this.valueInput(name).fill(String(value));
}
+ async selectEnumValue(name, value) {
+ await this.expectOpen();
+ const option = String(value).length === 0 ? { label: '""' } : String(value);
+ await this.enumSelect(name).selectOption(option);
+ }
+
async apply() {
await this.expectOpen();
await this.applyButton.click();
diff --git a/apps/web/src/tests/browser/shared/abstractions/components/schema-editor.component.js b/apps/web/src/tests/browser/shared/abstractions/components/schema-editor.component.js
index 850d875e..b94521e6 100644
--- a/apps/web/src/tests/browser/shared/abstractions/components/schema-editor.component.js
+++ b/apps/web/src/tests/browser/shared/abstractions/components/schema-editor.component.js
@@ -134,7 +134,7 @@ class SchemaEditorComponent {
const mapped = this.resolveField(field);
const input = this.row(index).locator(`[data-field="${mapped}"]`);
await input.fill(String(value));
- await input.blur();
+ await this.page.keyboard.press('Tab');
}
async getRowField(index, field) {
@@ -187,11 +187,23 @@ class SchemaEditorComponent {
}
async editRowParamsWithDialog(index, valuesByName) {
+ await this.editRowParamsWithDialogFlow(index, valuesByName, async (name, value) => {
+ await this.paramsEditor.setValue(name, value);
+ });
+ }
+
+ async editRowEnumParamsWithDialog(index, valuesByName) {
+ await this.editRowParamsWithDialogFlow(index, valuesByName, async (name, value) => {
+ await this.paramsEditor.selectEnumValue(name, value);
+ });
+ }
+
+ async editRowParamsWithDialogFlow(index, valuesByName, setParamValue) {
await this.ensureSchemaMode();
await this.dismissOpenHelpTooltips();
await this.row(index).locator('[data-action="edit-params"]').click();
for (const [name, value] of Object.entries(valuesByName || {})) {
- await this.paramsEditor.setValue(name, value);
+ await setParamValue(name, value);
}
await this.paramsEditor.apply();
}
diff --git a/docs/domain-faker-param-comparison.md b/docs/domain-faker-param-comparison.md
index 94b11078..476ca519 100644
--- a/docs/domain-faker-param-comparison.md
+++ b/docs/domain-faker-param-comparison.md
@@ -94,4 +94,3 @@ Generated by `node scripts/compare-domain-faker-params.mjs --markdown` from the
- `Missing in domain` must stay at `none` for every row.
- `Domain-only params` must stay at `none` for every Faker-backed domain command.
-
diff --git a/packages/core-ui/js/gui_components/shared/schema-row-rule-mapper.js b/packages/core-ui/js/gui_components/shared/schema-row-rule-mapper.js
index ce10f320..76217ddc 100644
--- a/packages/core-ui/js/gui_components/shared/schema-row-rule-mapper.js
+++ b/packages/core-ui/js/gui_components/shared/schema-row-rule-mapper.js
@@ -52,6 +52,14 @@ function isDomainEnumCommand(commandValue) {
return /^(?:datatype\.enum|awd\.datatype\.enum)$/i.test(String(commandValue || '').trim());
}
+function buildEditableEnumRuleSpec(enumInput, fallbackRuleSpec) {
+ try {
+ return EnumParser.buildSchemaRuleSpecFromInput(enumInput);
+ } catch {
+ return String(fallbackRuleSpec ?? enumInput ?? '').trim();
+ }
+}
+
function buildRuleSpecFromSchemaRow(row) {
const sourceType = normaliseSourceType(row?.sourceType);
if (sourceType === SOURCE_TYPE_FAKER) {
@@ -65,7 +73,7 @@ function buildRuleSpecFromSchemaRow(row) {
allowUnwrapped: isDomainEnumCommand(command),
});
if (isDomainEnumCommand(command)) {
- return EnumParser.buildSchemaRuleSpecFromInput(params);
+ return buildEditableEnumRuleSpec(params, `${command}${normaliseCommandParams(params)}`);
}
return `${command}${params}`;
}
@@ -92,7 +100,7 @@ function buildRuleSpecFromSchemaRow(row) {
return `regex(${regexValue})`;
}
if (sourceType === SOURCE_TYPE_ENUM) {
- return EnumParser.buildSchemaRuleSpecFromInput(row?.value);
+ return buildEditableEnumRuleSpec(row?.value);
}
return String(row?.value ?? '').trim();
}
diff --git a/packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js b/packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js
index dd52755d..d11ed369 100644
--- a/packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js
+++ b/packages/core-ui/js/gui_components/shared/test-data/help/help-model-builder.js
@@ -76,6 +76,9 @@ function extractSimpleDefaultValue(param = {}) {
function normalizeHelpParam(param = {}) {
return {
...param,
+ allowedValues: Array.isArray(param.allowedValues) ? param.allowedValues : [],
+ choices: Array.isArray(param.choices) ? param.choices : [],
+ enumValues: Array.isArray(param.enumValues) ? param.enumValues : [],
optional: param.optional === true || param.required === false,
defaultValue: extractSimpleDefaultValue(param),
};
diff --git a/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js
index f28af46b..fb7ef209 100644
--- a/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js
+++ b/packages/core-ui/js/gui_components/shared/test-data/schema/shared-schema-editor-controller.js
@@ -116,6 +116,14 @@ function createSharedSchemaEditorController({
elements.constraintsSummaryElement || getElementByRole(SCHEMA_CONSTRAINTS_SUMMARY_ROLE);
const getConstraintsTextElement = () =>
elements.constraintsTextElement || getElementByRole(SCHEMA_CONSTRAINTS_TEXT_ROLE);
+ const focusParamsButtonForRow = (rowId) => {
+ if (!rowId) {
+ return;
+ }
+ Array.from(rootElement?.querySelectorAll?.('[data-action="edit-params"]') || [])
+ .find((button) => button.getAttribute('data-row-id') === rowId)
+ ?.focus?.();
+ };
const refreshHelpHints = () => {
updateHelpHints?.();
@@ -874,6 +882,7 @@ function createSharedSchemaEditorController({
renderRows();
syncTextFromRows();
scheduleSemanticValidationForRow(rowId, { immediate: true });
+ focusParamsButtonForRow(rowId);
}
} catch (error) {
console.error('Failed opening params editor dialog.', error);
diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css
index b663c276..cc545661 100644
--- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css
+++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css
@@ -88,7 +88,8 @@ body.theme-dark {
.params-editor-close,
.params-editor-footer button,
-[data-role='params-editor-value'] {
+[data-role='params-editor-value'],
+[data-role='params-editor-enum'] {
border: 1px solid var(--pe-border, #ddd);
border-radius: 8px;
background: var(--pe-bg, #fff);
@@ -176,7 +177,8 @@ body.theme-dark {
flex: 0 0 auto;
}
-[data-role='params-editor-value'] {
+[data-role='params-editor-value'],
+[data-role='params-editor-enum'] {
display: block;
width: 100%;
max-width: 100%;
@@ -185,6 +187,10 @@ body.theme-dark {
box-sizing: border-box;
}
+[data-role='params-editor-enum'] {
+ min-height: 38px;
+}
+
.sr-only {
position: absolute;
width: 1px;
@@ -269,17 +275,91 @@ body.theme-dark {
}
.params-editor-modal button:focus-visible,
-.params-editor-modal input:focus-visible {
+.params-editor-modal input:focus-visible,
+.params-editor-modal select:focus-visible {
outline: 2px solid var(--pe-focus, #4a80ff);
outline-offset: 2px;
}
@media (max-width: 860px) {
+ .params-editor-overlay {
+ padding: 12px;
+ }
+
.params-editor-modal {
width: 100%;
}
+ .params-editor-table-wrap {
+ overflow: visible;
+ }
+}
+
+@media (max-width: 560px) {
+ .params-editor-header,
+ .params-editor-footer {
+ padding: 10px 12px;
+ }
+
+ .params-editor-body {
+ padding: 12px;
+ }
+
+ .params-editor-table,
+ .params-editor-table thead,
+ .params-editor-table tbody,
+ .params-editor-table tr,
+ .params-editor-table th,
+ .params-editor-table td {
+ display: block;
+ }
+
.params-editor-table {
- min-width: 720px;
+ border: 0;
+ }
+
+ .params-editor-table thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ }
+
+ .params-editor-table tr {
+ margin-bottom: 12px;
+ border: 1px solid var(--pe-border, #ddd);
+ border-radius: 8px;
+ overflow: hidden;
+ }
+
+ .params-editor-table tr:last-child {
+ margin-bottom: 0;
+ }
+
+ .params-editor-table td {
+ display: grid;
+ grid-template-columns: minmax(64px, 26%) minmax(0, 1fr);
+ gap: 10px;
+ align-items: start;
+ border-bottom: 1px solid var(--pe-border, #ddd);
+ }
+
+ .params-editor-table td::before {
+ content: attr(data-label);
+ color: var(--pe-muted, #555);
+ font-size: 12px;
+ font-weight: 700;
+ }
+
+ .params-editor-table td:last-child {
+ border-bottom: 0;
+ }
+
+ .params-editor-name-cell,
+ .params-editor-boolean-group,
+ [data-role='params-editor-value'],
+ [data-role='params-editor-enum'] {
+ min-width: 0;
}
}
diff --git a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js
index 7f349152..b0591dd2 100644
--- a/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js
+++ b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.js
@@ -2,6 +2,8 @@ import { createHelpTooltipService } from '../../../../help/help-tooltips.js';
import { getDefaultDocumentObj, getDefaultWindowObj, resolveWindowObj } from '../../dom/default-objects.js';
const STYLE_ID = 'params-editor-modal-styles-link';
+const ENUM_UNSET_VALUE = '__params_editor_unset__';
+const ENUM_EMPTY_VALUE = '__params_editor_empty__';
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
@@ -190,6 +192,92 @@ function inferEditorMode(rawValue, paramType = '') {
return 'auto';
}
+function normalizeChoiceValues(values = [], { allowEmpty = false } = {}) {
+ if (!Array.isArray(values)) {
+ return [];
+ }
+ return [
+ ...new Set(values.map((value) => String(value ?? '').trim()).filter((value) => allowEmpty || value.length > 0)),
+ ];
+}
+
+function isPrimitiveTypeToken(token = '') {
+ return /^(?:array|bigint|bool|boolean|date|decimal|double|float|function|int|integer|json|list|map|null|number|object|record|regexp?|string|tuple|undefined|unknown|void)$/iu.test(
+ String(token || '').trim()
+ );
+}
+
+function isLiteralChoiceTypeToken(token = '') {
+ const value = String(token || '').trim();
+ return (
+ value.length > 0 &&
+ !/\s/u.test(value) &&
+ !/[{}()[\],;:]/u.test(value) &&
+ !/[<>]/u.test(value) &&
+ !/=>/u.test(value) &&
+ !isPrimitiveTypeToken(value)
+ );
+}
+
+function inferEnumChoicesFromType(paramType = '') {
+ const type = String(paramType || '').trim();
+ if (!type || !type.includes('|')) {
+ return [];
+ }
+ if (/\s\|\s/u.test(type) || /[{}()[\],;:]/u.test(type) || /=>/u.test(type)) {
+ return [];
+ }
+ const tokens = type.split('|').map((token) => token.trim());
+ if (tokens.length < 2 || tokens.some((token) => !isLiteralChoiceTypeToken(token))) {
+ return [];
+ }
+ return normalizeChoiceValues(tokens);
+}
+
+function resolveEnumChoices(param = {}) {
+ const explicitChoiceSets = [param.allowedValues, param.choices, param.enumValues];
+ for (const choiceSet of explicitChoiceSets) {
+ const choices = normalizeChoiceValues(choiceSet, { allowEmpty: true });
+ if (choices.length > 0) {
+ return choices;
+ }
+ }
+ return inferEnumChoicesFromType(param.type);
+}
+
+function normalizeParamMetadata(param = {}, value = '', mode = 'auto', { isSet = null } = {}) {
+ const resolvedValue = String(value ?? '');
+ const entry = {
+ name: param?.name || '',
+ type: param?.type || '',
+ optional: param?.optional === true,
+ variadic: param?.variadic === true,
+ positionalOnly: param?.positionalOnly === true,
+ description: param?.description || '',
+ example: param?.example || '',
+ examples: Array.isArray(param?.examples) ? param.examples : [],
+ defaultValue: String(param?.defaultValue ?? ''),
+ min: param?.min,
+ minimum: param?.minimum,
+ max: param?.max,
+ maximum: param?.maximum,
+ pattern: param?.pattern,
+ multipleOf: param?.multipleOf,
+ allowedValues: Array.isArray(param?.allowedValues) ? param.allowedValues : [],
+ choices: Array.isArray(param?.choices) ? param.choices : [],
+ enumValues: Array.isArray(param?.enumValues) ? param.enumValues : [],
+ value: resolvedValue,
+ isSet: isSet ?? resolvedValue.trim().length > 0,
+ mode,
+ };
+ const enumChoices = resolveEnumChoices(entry);
+ return {
+ ...entry,
+ enumChoices,
+ mode: enumChoices.length > 0 ? 'enum' : mode,
+ };
+}
+
function parseInitialParamEntries({ params = [], initialParams = '' } = {}) {
const metadata = Array.isArray(params) ? params : [];
const variadicIndex = metadata.findIndex((param) => param?.variadic === true);
@@ -197,28 +285,11 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) {
const trimmed = stripOuterParens(initialParams);
if (!trimmed) {
return {
- entries: metadata.map((param) => ({
- name: param?.name || '',
- type: param?.type || '',
- optional: param?.optional === true,
- variadic: param?.variadic === true,
- positionalOnly: param?.positionalOnly === true,
- description: param?.description || '',
- example: param?.example || '',
- examples: Array.isArray(param?.examples) ? param.examples : [],
- defaultValue: String(param?.defaultValue ?? ''),
- min: param?.min,
- minimum: param?.minimum,
- max: param?.max,
- maximum: param?.maximum,
- pattern: param?.pattern,
- multipleOf: param?.multipleOf,
- allowedValues: Array.isArray(param?.allowedValues) ? param.allowedValues : [],
- choices: Array.isArray(param?.choices) ? param.choices : [],
- enumValues: Array.isArray(param?.enumValues) ? param.enumValues : [],
- value: String(param?.defaultValue ?? ''),
- mode: 'auto',
- })),
+ entries: metadata.map((param) =>
+ normalizeParamMetadata(param, param?.defaultValue ?? '', 'auto', {
+ isSet: String(param?.defaultValue ?? '').trim().length > 0,
+ })
+ ),
error: '',
};
}
@@ -285,28 +356,12 @@ function parseInitialParamEntries({ params = [], initialParams = '' } = {}) {
return {
entries: metadata.map((param, index) => {
const rawValue = assignedValues[index] || '';
- return {
- name: param?.name || '',
- type: param?.type || '',
- optional: param?.optional === true,
- variadic: param?.variadic === true,
- positionalOnly: param?.positionalOnly === true,
- description: param?.description || '',
- example: param?.example || '',
- examples: Array.isArray(param?.examples) ? param.examples : [],
- defaultValue: String(param?.defaultValue ?? ''),
- min: param?.min,
- minimum: param?.minimum,
- max: param?.max,
- maximum: param?.maximum,
- pattern: param?.pattern,
- multipleOf: param?.multipleOf,
- allowedValues: Array.isArray(param?.allowedValues) ? param.allowedValues : [],
- choices: Array.isArray(param?.choices) ? param.choices : [],
- enumValues: Array.isArray(param?.enumValues) ? param.enumValues : [],
- value: rawValue ? unquoteValue(rawValue) : '',
- mode: inferEditorMode(rawValue, param?.type || ''),
- };
+ return normalizeParamMetadata(
+ param,
+ rawValue ? unquoteValue(rawValue) : '',
+ inferEditorMode(rawValue, param?.type || ''),
+ { isSet: rawValue.length > 0 }
+ );
}),
error: '',
};
@@ -318,17 +373,81 @@ function isRawPreferredType(paramType = '') {
);
}
+function isPlainStringType(paramType = '') {
+ return (
+ String(paramType || '')
+ .trim()
+ .toLowerCase() === 'string'
+ );
+}
+
+function isStringCapableUnionType(paramType = '') {
+ const normalizedType = String(paramType || '');
+ return !isPlainStringType(normalizedType) && /\bstring\b/iu.test(normalizedType);
+}
+
+function isParserNumericLiteral(value = '') {
+ return /^-?\d+(?:\.\d+)?$/u.test(String(value ?? '').trim());
+}
+
+function isBooleanCapableType(paramType = '') {
+ return /\b(?:bool|boolean)\b/iu.test(String(paramType || ''));
+}
+
+function isStructuredRawCapableType(paramType = '') {
+ return /\b(?:array|list|object|json|record|map|tuple)\b/iu.test(String(paramType || ''));
+}
+
+function isNumericEnumToken(value = '') {
+ return /^[+-]?\d+(?:\.\d+)?$/u.test(String(value ?? '').trim());
+}
+
function validateBalancedRawValue(value) {
return splitTopLevelCommaSeparated(`[${String(value ?? '').trim()}]`).error.replace(/^Current params/u, 'Raw value');
}
+function shouldKeepStringUnionValueRaw(rawValue = '', paramType = '') {
+ const trimmedValue = String(rawValue ?? '').trim();
+ if (isParserNumericLiteral(trimmedValue)) {
+ return true;
+ }
+ if (isBooleanCapableType(paramType) && /^(?:true|false)$/u.test(trimmedValue)) {
+ return true;
+ }
+ if (
+ isStructuredRawCapableType(paramType) &&
+ (trimmedValue.startsWith('[') || trimmedValue.startsWith('{')) &&
+ !validateBalancedRawValue(trimmedValue)
+ ) {
+ return true;
+ }
+ return false;
+}
+
function formatEditorValue(value, mode, paramType = '') {
const rawValue = String(value ?? '');
+ if (mode === 'enum' && rawValue.length === 0) {
+ return JSON.stringify('');
+ }
if (rawValue.trim().length === 0) {
return '';
}
- const resolvedMode = mode === 'auto' ? (isRawPreferredType(paramType) ? 'raw' : 'text') : mode;
+ const resolvedMode =
+ mode === 'enum' && isNumericEnumToken(rawValue)
+ ? 'raw'
+ : mode === 'auto'
+ ? isStringCapableUnionType(paramType)
+ ? shouldKeepStringUnionValueRaw(rawValue, paramType)
+ ? 'raw'
+ : 'text'
+ : isRawPreferredType(paramType)
+ ? 'raw'
+ : 'text'
+ : mode;
+ if (mode === 'enum' && !isNumericEnumToken(rawValue)) {
+ return JSON.stringify(unquoteValue(rawValue));
+ }
if (resolvedMode === 'raw') {
return rawValue.trim();
}
@@ -337,8 +456,9 @@ function formatEditorValue(value, mode, paramType = '') {
function buildParamsTextFromEditorEntries({ entries = [], validateParams = null } = {}) {
const normalizedEntries = Array.isArray(entries) ? entries : [];
+ const hasEntryValue = (entry = {}) => entry.isSet === true || String(entry?.value ?? '').trim().length > 0;
const lastFilledIndex = normalizedEntries.reduce(
- (lastIndex, entry, index) => (String(entry?.value ?? '').trim().length > 0 ? index : lastIndex),
+ (lastIndex, entry, index) => (hasEntryValue(entry) ? index : lastIndex),
-1
);
const errors = [];
@@ -358,7 +478,7 @@ function buildParamsTextFromEditorEntries({ entries = [], validateParams = null
const entry = normalizedEntries[index] || {};
const rawValue = String(entry.value ?? '');
const trimmedValue = rawValue.trim();
- if (!trimmedValue) {
+ if (!hasEntryValue(entry)) {
if (entry.optional === true) {
continue;
}
@@ -437,7 +557,7 @@ function buildParamValidationRules(entry = {}) {
rules.push(`Pattern: ${pattern}`);
}
- const allowedValues = entry.allowedValues || entry.choices || entry.enumValues || [];
+ const allowedValues = resolveEnumChoices(entry);
if (Array.isArray(allowedValues) && allowedValues.length > 0) {
rules.push(`Allowed values: ${allowedValues.join(', ')}`);
}
@@ -497,6 +617,37 @@ function isBooleanParamType(paramType = '') {
return /\b(bool|boolean)\b/iu.test(String(paramType || ''));
}
+function isEnumParam(entry = {}) {
+ return Array.isArray(entry.enumChoices) && entry.enumChoices.length > 0;
+}
+
+function renderEnumValueEditor(entry, index) {
+ const value = String(entry.value ?? '').trim();
+ const optionValues = Array.isArray(entry.enumChoices) ? entry.enumChoices : [];
+ const hasMatchingValue = optionValues.includes(value) && (value.length > 0 || entry.isSet === true);
+ const resolvedValue = hasMatchingValue ? value : ENUM_UNSET_VALUE;
+ const emptyOptionLabel = entry.optional === true ? 'Unset' : 'Select...';
+ return `
+
+ `;
+}
+
function renderValueEditor(entry, index) {
if (isBooleanParamType(entry.type || '')) {
const value = String(entry.value ?? '')
@@ -549,6 +700,10 @@ function renderValueEditor(entry, index) {
`;
}
+ if (isEnumParam(entry)) {
+ return renderEnumValueEditor(entry, index);
+ }
+
return `
0,
+ };
}
- return (
- rootElement.querySelector(`[data-role="params-editor-value"][data-index="${index}"]`)?.value ?? entry?.value ?? ''
- );
+ if (isEnumParam(entry)) {
+ const enumInput = rootElement.querySelector(`[data-role="params-editor-enum"][data-index="${index}"]`);
+ const selectedOption = enumInput?.selectedOptions?.[0] || null;
+ if (selectedOption?.hasAttribute('data-enum-value')) {
+ return {
+ value: selectedOption.getAttribute('data-enum-value') ?? '',
+ isSet: true,
+ };
+ }
+ return {
+ value: '',
+ isSet: false,
+ };
+ }
+
+ const value =
+ rootElement.querySelector(`[data-role="params-editor-value"][data-index="${index}"]`)?.value ?? entry?.value ?? '';
+ return {
+ value,
+ isSet: String(value ?? '').trim().length > 0,
+ };
}
function renderEntryRows(entries = []) {
@@ -586,7 +763,7 @@ function renderEntryRows(entries = []) {
const requiredStateAccessibleLabel = `${requiredStateLabel} ${entry.name}`;
return `
- |
+ |
${nameLabel}
|
- ${escapeHtml(entry.type || 'unknown')} |
-
+ | ${escapeHtml(entry.type || 'unknown')} |
+
|
-
+ |
${renderValueEditor(entry, index)}
${
entry.defaultValue
@@ -766,6 +943,13 @@ function openParamsEditorModal({
const dialogElement = overlay.querySelector('[data-role="params-editor-dialog"]');
const valueInputs = () => Array.from(overlay.querySelectorAll('[data-role="params-editor-value"]'));
const booleanInputs = () => Array.from(overlay.querySelectorAll('[data-role="params-editor-boolean"]'));
+ const enumInputs = () => Array.from(overlay.querySelectorAll('[data-role="params-editor-enum"]'));
+ const editorInputs = () =>
+ Array.from(
+ overlay.querySelectorAll(
+ '[data-role="params-editor-value"], [data-role="params-editor-enum"], [data-role="params-editor-boolean"]'
+ )
+ );
const helpTooltipService = createHelpTooltipService({
documentObj,
windowObj,
@@ -799,10 +983,13 @@ function openParamsEditorModal({
return;
}
- currentEntries = currentEntries.map((entry, index) => ({
- ...entry,
- value: readRenderedEntryValue(overlay, entry, index),
- }));
+ currentEntries = currentEntries.map((entry, index) => {
+ const state = readRenderedEntryState(overlay, entry, index);
+ return {
+ ...entry,
+ ...state,
+ };
+ });
const result = buildParamsTextFromEditorEntries({
entries: currentEntries,
validateParams,
@@ -878,11 +1065,14 @@ function openParamsEditorModal({
booleanInputs().forEach((input) => {
input.addEventListener('change', syncPreview);
});
+ enumInputs().forEach((input) => {
+ input.addEventListener('change', syncPreview);
+ });
documentObj.body.appendChild(overlay);
helpTooltipService.update();
syncPreview();
- const firstInput = valueInputs()[0] || booleanInputs()[0];
+ const firstInput = editorInputs()[0];
const focusFn = windowObj?.requestAnimationFrame?.bind(windowObj) || windowObj?.setTimeout?.bind(windowObj);
focusFn?.(() => (firstInput || getFocusableElements(dialogElement)[0] || dialogElement)?.focus?.());
});
@@ -892,6 +1082,8 @@ export {
splitTopLevelCommaSeparated,
parseInitialParamEntries,
inferEditorMode,
+ inferEnumChoicesFromType,
+ resolveEnumChoices,
buildParamsTextFromEditorEntries,
openParamsEditorModal,
};
diff --git a/packages/core-ui/src/tests/generator/schema-row-rule-mapper.test.js b/packages/core-ui/src/tests/generator/schema-row-rule-mapper.test.js
index 6a191722..241cf33e 100644
--- a/packages/core-ui/src/tests/generator/schema-row-rule-mapper.test.js
+++ b/packages/core-ui/src/tests/generator/schema-row-rule-mapper.test.js
@@ -77,6 +77,13 @@ describe('schema-row-rule-mapper', () => {
params: 'active,inactive,pending',
})
).toBe('enum("active","inactive","pending")');
+ expect(
+ buildRuleSpecFromSchemaRow({
+ sourceType: 'domain',
+ command: 'datatype.enum',
+ params: 'csv="active,,pending"',
+ })
+ ).toBe('datatype.enum(csv="active,,pending")');
});
test('buildRuleSpecFromSchemaRow handles literal rows including blank default', () => {
@@ -100,6 +107,8 @@ describe('schema-row-rule-mapper', () => {
expect(buildRuleSpecFromSchemaRow({ sourceType: 'enum', value: 'enum a,b,c' })).toBe('enum("a","b","c")');
expect(buildRuleSpecFromSchemaRow({ sourceType: 'enum', value: '(a,b,c)' })).toBe('enum("a","b","c")');
expect(buildRuleSpecFromSchemaRow({ sourceType: 'enum', value: '"a","b","c"' })).toBe('enum("a","b","c")');
+ expect(buildRuleSpecFromSchemaRow({ sourceType: 'enum', value: '"",active' })).toBe('enum("","active")');
+ expect(buildRuleSpecFromSchemaRow({ sourceType: 'enum', value: 'active,,pending' })).toBe('active,,pending');
expect(buildRuleSpecFromSchemaRow({ sourceType: 'enum', value: ' ' })).toBe('');
});
diff --git a/packages/core-ui/src/tests/shared/help-model-builder.test.js b/packages/core-ui/src/tests/shared/help-model-builder.test.js
index e8646011..4e481194 100644
--- a/packages/core-ui/src/tests/shared/help-model-builder.test.js
+++ b/packages/core-ui/src/tests/shared/help-model-builder.test.js
@@ -133,9 +133,16 @@ describe('help-model-builder', () => {
test('builds domain help for auto-increment timestamps with step metadata', () => {
const model = buildSchemaHelpModel('domain', 'autoIncrement.timestamp');
+ const typeParam = model.params.find((param) => param.name === 'type');
expect(model.show).toBe(true);
expect(model.heading).toContain('autoIncrement.timestamp');
+ expect(typeParam).toEqual(
+ expect.objectContaining({
+ type: 'enum',
+ enumValues: ['milliseconds', 'seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years'],
+ })
+ );
expect(renderSchemaHelpHtml(model)).toContain('run start time');
expect(renderSchemaHelpHtml(model)).toContain('outputFormat');
});
diff --git a/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js b/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js
index c3dd3ddb..6635b591 100644
--- a/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js
+++ b/packages/core-ui/src/tests/shared/shared-schema-definition-view.test.js
@@ -242,8 +242,8 @@ describe('shared-schema-definition view', () => {
const textArea = document.querySelector('[data-role="schema-textbox"]');
const invalidEnumCases = [
{
- schemaText: 'Status\ndatatype.enum(values="")',
- expectedMessage: 'Invalid keyword arguments: argument "values" is required',
+ schemaText: 'Status\ndatatype.enum(csv="active,,pending")',
+ expectedMessage: 'Invalid keyword arguments: enum values cannot be empty',
},
{
schemaText: 'Status\ndatatype.enum()',
diff --git a/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js b/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js
index 76b18e46..a2ff1c25 100644
--- a/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js
+++ b/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js
@@ -207,6 +207,83 @@ describe('createSharedSchemaEditorController', () => {
expect(dom.window.document.querySelector('[data-field="params"]').value).toBe('(values=active,inactive,pending)');
});
+ test('applies enum picker params back to the row and emits synced schema text', async () => {
+ const root = createRoot(dom.window.document);
+ const onSchemaTextChanged = jest.fn();
+ const dataRulesToSchemaText = jest.fn(({ dataRules = [] } = {}) => {
+ const rule = dataRules[0] || {};
+ return { text: `${rule.name}\n${rule.ruleSpec}` };
+ });
+ const controller = createSharedSchemaEditorController({
+ documentObj: dom.window.document,
+ rootElement: root,
+ createBlankRow: () => ({
+ id: 'row-1',
+ name: 'Country Code',
+ sourceType: 'domain',
+ command: 'location.countryCode',
+ value: '',
+ params: '',
+ semanticValidationIssues: [],
+ }),
+ mapRuleToRow: () => ({
+ id: 'row-1',
+ name: 'Country Code',
+ sourceType: 'domain',
+ command: 'location.countryCode',
+ value: '',
+ params: '',
+ semanticValidationIssues: [],
+ }),
+ schemaTextToDataRules: jest.fn(() => ({ dataRules: [], errors: [] })),
+ dataRulesToSchemaText,
+ onSchemaTextChanged,
+ getMethodPickerOptions: () => [
+ {
+ sourceType: 'domain',
+ command: 'location.countryCode',
+ helpModel: {
+ heading: 'location.countryCode',
+ summary: 'Country code helper',
+ params: [{ name: 'variant', type: 'enum', enumValues: ['alpha-2', 'alpha-3', 'numeric'], optional: true }],
+ },
+ },
+ ],
+ getVisibleDomainCommands: () => ['location.countryCode'],
+ validateSchemaRows: jest.fn((rows) => ({ rows, errors: [] })),
+ updatePairwiseButtonVisibility: jest.fn(),
+ updateHelpHints: jest.fn(),
+ });
+
+ controller.init();
+ const paramsButton = dom.window.document.querySelector('[data-action="edit-params"]');
+ const dialogPromise = controller.handleClick({ target: paramsButton });
+
+ const dialog = within(dom.window.document.body).getByRole('dialog', {
+ name: /edit params for location\.countrycode/i,
+ });
+ const variantSelect = within(dialog).getByRole('combobox', { name: /variant value/i });
+ variantSelect.value = 'alpha-3';
+ fireEvent.change(variantSelect);
+ fireEvent.click(within(dialog).getByRole('button', { name: /^apply$/i }));
+
+ await dialogPromise;
+
+ expect(dom.window.document.querySelector('[data-field="params"]').value).toBe('(variant="alpha-3")');
+ expect(dom.window.document.activeElement).toBe(dom.window.document.querySelector('[data-action="edit-params"]'));
+ expect(dataRulesToSchemaText).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ dataRules: [
+ expect.objectContaining({
+ name: 'Country Code',
+ ruleSpec: 'location.countryCode(variant="alpha-3")',
+ }),
+ ],
+ })
+ );
+ expect(onSchemaTextChanged).toHaveBeenLastCalledWith('Country Code\nlocation.countryCode(variant="alpha-3")');
+ });
+
test('restores focus to the command picker button after applying a method picker selection', async () => {
const root = createRoot(dom.window.document);
const controller = createSharedSchemaEditorController({
diff --git a/packages/core-ui/src/tests/utils/faker-command-help-metadata.test.js b/packages/core-ui/src/tests/utils/faker-command-help-metadata.test.js
index ecd81f2a..7089a49d 100644
--- a/packages/core-ui/src/tests/utils/faker-command-help-metadata.test.js
+++ b/packages/core-ui/src/tests/utils/faker-command-help-metadata.test.js
@@ -62,7 +62,9 @@ describe('faker command help metadata', () => {
const uuid = getFakerCommandHelp('string.uuid');
expect(firstName.params).toEqual(
- expect.arrayContaining([expect.objectContaining({ name: 'sex', optional: true, type: 'female|male' })])
+ expect.arrayContaining([
+ expect.objectContaining({ name: 'sex', optional: true, type: 'enum', enumValues: ['female', 'male'] }),
+ ])
);
expect(firstName.usageExamples).toEqual(
expect.arrayContaining([
@@ -82,7 +84,7 @@ describe('faker command help metadata', () => {
expect(uuid.summary).toContain('Returns a UUID');
expect(uuid.params).toEqual(
expect.arrayContaining([
- expect.objectContaining({ name: 'version', optional: true, type: '4|7' }),
+ expect.objectContaining({ name: 'version', optional: true, type: 'enum', enumValues: ['4', '7'] }),
expect.objectContaining({ name: 'refDate', optional: true, type: 'string|number|date' }),
])
);
diff --git a/packages/core-ui/src/tests/utils/params-editor-modal.test.js b/packages/core-ui/src/tests/utils/params-editor-modal.test.js
index 2a9a7902..f31e9bc1 100644
--- a/packages/core-ui/src/tests/utils/params-editor-modal.test.js
+++ b/packages/core-ui/src/tests/utils/params-editor-modal.test.js
@@ -1,9 +1,12 @@
import { JSDOM } from 'jsdom';
+import { readFileSync } from 'node:fs';
import { fireEvent, within } from '@testing-library/dom';
import { jest } from '@jest/globals';
import {
splitTopLevelCommaSeparated,
parseInitialParamEntries,
+ inferEnumChoicesFromType,
+ resolveEnumChoices,
buildParamsTextFromEditorEntries,
openParamsEditorModal,
} from '../../../js/gui_components/shared/test-data/ui/params-editor-modal.js';
@@ -72,6 +75,47 @@ describe('params editor modal', () => {
]);
});
+ test('derives enum choices from explicit enum value arrays before pipe-delimited types', () => {
+ expect(
+ resolveEnumChoices({
+ type: 'alpha-2|alpha-3|numeric',
+ allowedValues: ['svg-uri', 'svg-base64'],
+ choices: ['ignored'],
+ enumValues: ['also-ignored'],
+ })
+ ).toEqual(['svg-uri', 'svg-base64']);
+ expect(resolveEnumChoices({ type: 'enum', enumValues: ['alpha-2', 'alpha-3', 'numeric'] })).toEqual([
+ 'alpha-2',
+ 'alpha-3',
+ 'numeric',
+ ]);
+ expect(resolveEnumChoices({ type: 'alpha-2|alpha-3|numeric' })).toEqual(['alpha-2', 'alpha-3', 'numeric']);
+ expect(resolveEnumChoices({ type: 'female|generic|male' })).toEqual(['female', 'generic', 'male']);
+ });
+
+ test('does not derive enum choices from broad type unions', () => {
+ expect(inferEnumChoicesFromType('string|number|date')).toEqual([]);
+ expect(inferEnumChoicesFromType('comma-separated list|array')).toEqual([]);
+ expect(inferEnumChoicesFromType('number | { min: number; max: number; }')).toEqual([]);
+ expect(inferEnumChoicesFromType('array | () => unknown')).toEqual([]);
+ });
+
+ test('parses explicit enum metadata into enum editor entries', () => {
+ const parsed = parseInitialParamEntries({
+ params: [
+ { name: 'sex', type: 'enum', enumValues: ['female', 'male'], optional: true },
+ { name: 'refDate', type: 'string|number|date', optional: true },
+ ],
+ initialParams: '',
+ });
+
+ expect(parsed.error).toBe('');
+ expect(parsed.entries).toEqual([
+ expect.objectContaining({ name: 'sex', enumChoices: ['female', 'male'], mode: 'enum' }),
+ expect.objectContaining({ name: 'refDate', enumChoices: [], mode: 'auto' }),
+ ]);
+ });
+
test('parses variadic documented params as a single editable list value', () => {
const parsed = parseInitialParamEntries({
params: [{ name: 'values', type: 'comma-separated list', optional: false, variadic: true }],
@@ -140,6 +184,80 @@ describe('params editor modal', () => {
});
});
+ test('auto quotes non-numeric values for string-capable union params', () => {
+ expect(
+ buildParamsTextFromEditorEntries({
+ entries: [
+ { name: 'refDate', type: 'string|number|date', value: '2026-06-18T00:00:00.000Z', mode: 'auto' },
+ { name: 'start', type: 'string|number', value: '2026-06-12T12:39:23Z', mode: 'auto' },
+ ],
+ })
+ ).toEqual({
+ paramsText: '(refDate="2026-06-18T00:00:00.000Z",start="2026-06-12T12:39:23Z")',
+ errors: [],
+ });
+ });
+
+ test('keeps parser-valid numeric values raw for string-capable union params', () => {
+ expect(
+ buildParamsTextFromEditorEntries({
+ entries: [
+ { name: 'integer', type: 'string|number', value: '123', mode: 'auto' },
+ { name: 'decimal', type: 'string|number', value: '12.5', mode: 'auto' },
+ { name: 'negative', type: 'string|number', value: '-12', mode: 'auto' },
+ { name: 'leadingZeros', type: 'string|number', value: '001', mode: 'auto' },
+ ],
+ })
+ ).toEqual({
+ paramsText: '(integer=123,decimal=12.5,negative=-12,leadingZeros=001)',
+ errors: [],
+ });
+ });
+
+ test('quotes number-like text that the domain parser does not treat as numeric', () => {
+ expect(
+ buildParamsTextFromEditorEntries({
+ entries: [
+ { name: 'plus', type: 'string|number', value: '+12', mode: 'auto' },
+ { name: 'exponent', type: 'string|number', value: '1e3', mode: 'auto' },
+ { name: 'infinity', type: 'string|number', value: 'Infinity', mode: 'auto' },
+ { name: 'notNumber', type: 'string|number', value: 'NaN', mode: 'auto' },
+ ],
+ })
+ ).toEqual({
+ paramsText: '(plus="+12",exponent="1e3",infinity="Infinity",notNumber="NaN")',
+ errors: [],
+ });
+ });
+
+ test('preserves raw structured values when string-capable union params allow them', () => {
+ expect(
+ buildParamsTextFromEditorEntries({
+ entries: [
+ { name: 'arrayValue', type: 'string|array', value: '["Ada","Bob"]', mode: 'auto' },
+ { name: 'objectValue', type: 'string|object', value: '{ name: "Ada" }', mode: 'auto' },
+ ],
+ })
+ ).toEqual({
+ paramsText: '(arrayValue=["Ada","Bob"],objectValue={ name: "Ada" })',
+ errors: [],
+ });
+ });
+
+ test('builds enum params with string choices quoted and numeric choices raw', () => {
+ expect(
+ buildParamsTextFromEditorEntries({
+ entries: [
+ { name: 'variant', type: 'enum', value: 'alpha-3', mode: 'enum', optional: true },
+ { name: 'version', type: 'enum', value: '7', mode: 'enum', optional: true },
+ ],
+ })
+ ).toEqual({
+ paramsText: '(variant="alpha-3",version=7)',
+ errors: [],
+ });
+ });
+
test('builds positional-only params without named assignment for faker object arguments', () => {
const result = buildParamsTextFromEditorEntries({
entries: [
@@ -216,6 +334,17 @@ describe('params editor modal', () => {
});
});
+ test('auto quotes string-capable union params without double quoting existing quoted input', () => {
+ const result = buildParamsTextFromEditorEntries({
+ entries: [{ name: 'refDate', type: 'string|number|date', value: '"2026-06-18T00:00:00.000Z"', mode: 'auto' }],
+ });
+
+ expect(result).toEqual({
+ paramsText: '(refDate="2026-06-18T00:00:00.000Z")',
+ errors: [],
+ });
+ });
+
test('switches to named params when later values skip optional gaps', () => {
const result = buildParamsTextFromEditorEntries({
entries: [
@@ -280,6 +409,265 @@ describe('params editor modal', () => {
await expect(promise).resolves.toBe('(active,inactive,pending)');
});
+ test('renders required enum params as a select and requires a choice', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'location.countryCode',
+ helpModel: {
+ summary: 'Country code helper',
+ params: [{ name: 'variant', type: 'enum', enumValues: ['alpha-2', 'alpha-3', 'numeric'], optional: false }],
+ },
+ initialParams: '',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for location\.countrycode/i });
+ const applyButton = within(dialog).getByRole('button', { name: /^apply$/i });
+ const variantSelect = within(dialog).getByRole('combobox', { name: /variant value/i });
+
+ expect(within(dialog).queryByRole('textbox', { name: /variant value/i })).toBeNull();
+ expect(variantSelect.options[0].selected).toBe(true);
+ expect(variantSelect.options[0].textContent).toBe('Select...');
+ expect(applyButton.disabled).toBe(true);
+ expect(dialog.querySelector('[data-role="params-editor-error"]').textContent).toContain('required');
+
+ variantSelect.value = 'alpha-3';
+ fireEvent.change(variantSelect);
+
+ expect(applyButton.disabled).toBe(false);
+ expect(
+ within(dialog).getByText('(variant="alpha-3")', {
+ selector: '[data-role="params-editor-preview"]',
+ })
+ ).toBeTruthy();
+
+ fireEvent.click(applyButton);
+ await expect(promise).resolves.toBe('(variant="alpha-3")');
+ });
+
+ test('renders param row cell labels used by the stacked mobile layout', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'location.countryCode',
+ helpModel: {
+ summary: 'Country code helper',
+ params: [{ name: 'variant', type: 'enum', enumValues: ['alpha-2', 'alpha-3', 'numeric'], optional: false }],
+ },
+ initialParams: '',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for location\.countrycode/i });
+ const cells = Array.from(dialog.querySelectorAll('.params-editor-table tbody tr:first-child td')).map((cell) =>
+ cell.getAttribute('data-label')
+ );
+ const variantSelect = within(dialog).getByRole('combobox', { name: /variant value/i });
+
+ expect(cells).toEqual(['Name', 'Type', 'Req', 'Value']);
+ expect(variantSelect.closest('td')?.getAttribute('data-label')).toBe('Value');
+
+ fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i }));
+ await expect(promise).resolves.toBeNull();
+ });
+
+ test('renders optional explicit enum choices with an unset option', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'image.dataUri',
+ helpModel: {
+ summary: 'Image data URI helper',
+ params: [{ name: 'type', type: 'string', optional: true, allowedValues: ['svg-uri', 'svg-base64'] }],
+ },
+ initialParams: '',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for image\.datauri/i });
+ const typeSelect = within(dialog).getByRole('combobox', { name: /type value/i });
+ const applyButton = within(dialog).getByRole('button', { name: /^apply$/i });
+
+ expect(typeSelect.options[0].selected).toBe(true);
+ expect(typeSelect.options[0].textContent).toBe('Unset');
+ expect(applyButton.disabled).toBe(false);
+
+ typeSelect.value = 'svg-base64';
+ fireEvent.change(typeSelect);
+
+ expect(
+ within(dialog).getByText('(type="svg-base64")', {
+ selector: '[data-role="params-editor-preview"]',
+ })
+ ).toBeTruthy();
+
+ fireEvent.click(applyButton);
+ await expect(promise).resolves.toBe('(type="svg-base64")');
+ });
+
+ test('serializes explicit empty string enum choices separately from optional unset', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'internet.mac',
+ helpModel: {
+ summary: 'MAC helper',
+ params: [{ name: 'separator', type: 'enum', optional: true, enumValues: [':', '-', ''] }],
+ },
+ initialParams: '',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for internet\.mac/i });
+ const separatorSelect = within(dialog).getByRole('combobox', { name: /separator value/i });
+ const emptyStringOption = Array.from(separatorSelect.options).find((option) => option.textContent === '""');
+
+ expect(separatorSelect.options[0].textContent).toBe('Unset');
+ expect(emptyStringOption).toBeDefined();
+
+ separatorSelect.value = emptyStringOption.value;
+ fireEvent.change(separatorSelect);
+
+ expect(
+ within(dialog).getByText('(separator="")', {
+ selector: '[data-role="params-editor-preview"]',
+ })
+ ).toBeTruthy();
+
+ fireEvent.click(within(dialog).getByRole('button', { name: /^apply$/i }));
+ await expect(promise).resolves.toBe('(separator="")');
+ });
+
+ test('prefills enum selects from existing params and numeric defaults', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'string.uuid',
+ helpModel: {
+ summary: 'UUID helper',
+ params: [
+ { name: 'version', type: 'enum', enumValues: ['4', '7'], optional: true, defaultValue: '7' },
+ { name: 'refDate', type: 'string|number|date', optional: true },
+ ],
+ },
+ initialParams: '(version=4)',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for string\.uuid/i });
+ const versionSelect = within(dialog).getByRole('combobox', { name: /version value/i });
+
+ expect(versionSelect.value).toBe('4');
+ expect(within(dialog).queryByRole('combobox', { name: /refdate value/i })).toBeNull();
+ expect(within(dialog).getByRole('textbox', { name: /refdate value/i })).toBeTruthy();
+ expect(
+ within(dialog).getByText('(version=4)', {
+ selector: '[data-role="params-editor-preview"]',
+ })
+ ).toBeTruthy();
+
+ fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i }));
+ await expect(promise).resolves.toBeNull();
+ });
+
+ test('auto quotes string.uuid refDate values entered without raw schema quotes', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'string.uuid',
+ helpModel: {
+ summary: 'UUID helper',
+ params: [
+ { name: 'version', type: 'enum', enumValues: ['4', '7'], optional: true },
+ { name: 'refDate', type: 'string|number|date', optional: true },
+ ],
+ },
+ initialParams: '',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for string\.uuid/i });
+ const versionSelect = within(dialog).getByRole('combobox', { name: /version value/i });
+ const refDateInput = within(dialog).getByRole('textbox', { name: /refdate value/i });
+ const applyButton = within(dialog).getByRole('button', { name: /^apply$/i });
+
+ versionSelect.value = '7';
+ fireEvent.change(versionSelect);
+ refDateInput.value = '2026-06-18T00:00:00.000Z';
+ fireEvent.input(refDateInput);
+
+ expect(applyButton.disabled).toBe(false);
+ expect(
+ within(dialog).getByText('(version=7,refDate="2026-06-18T00:00:00.000Z")', {
+ selector: '[data-role="params-editor-preview"]',
+ })
+ ).toBeTruthy();
+
+ fireEvent.click(applyButton);
+ await expect(promise).resolves.toBe('(version=7,refDate="2026-06-18T00:00:00.000Z")');
+ });
+
+ test('auto quotes autoIncrement timestamp start and enum type while keeping numeric step raw', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'autoIncrement.timestamp',
+ helpModel: {
+ summary: 'Timestamp helper',
+ params: [
+ { name: 'start', type: 'string|number', optional: true },
+ { name: 'step', type: 'number', optional: true, defaultValue: '1' },
+ { name: 'type', type: 'enum', enumValues: ['seconds', 'minutes', 'hours', 'days'], optional: true },
+ ],
+ },
+ initialParams: '',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for autoincrement\.timestamp/i });
+ const startInput = within(dialog).getByRole('textbox', { name: /start value/i });
+ const stepInput = within(dialog).getByRole('textbox', { name: /step value/i });
+ const typeSelect = within(dialog).getByRole('combobox', { name: /type value/i });
+ const applyButton = within(dialog).getByRole('button', { name: /^apply$/i });
+
+ startInput.value = '2026-06-12T12:39:23Z';
+ fireEvent.input(startInput);
+ stepInput.value = '15';
+ fireEvent.input(stepInput);
+ typeSelect.value = 'minutes';
+ fireEvent.change(typeSelect);
+
+ expect(applyButton.disabled).toBe(false);
+ expect(
+ within(dialog).getByText('(start="2026-06-12T12:39:23Z",step=15,type="minutes")', {
+ selector: '[data-role="params-editor-preview"]',
+ })
+ ).toBeTruthy();
+
+ fireEvent.click(applyButton);
+ await expect(promise).resolves.toBe('(start="2026-06-12T12:39:23Z",step=15,type="minutes")');
+ });
+
+ test('focuses the first editor control in rendered order when enum precedes text params', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'location.countryCode',
+ helpModel: {
+ summary: 'Country code helper',
+ params: [
+ { name: 'variant', type: 'enum', enumValues: ['alpha-2', 'alpha-3', 'numeric'], optional: true },
+ { name: 'locale', type: 'string', optional: true },
+ ],
+ },
+ initialParams: '',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for location\.countrycode/i });
+ const variantSelect = within(dialog).getByRole('combobox', { name: /variant value/i });
+
+ await new Promise((resolve) => window.setTimeout(resolve, 0));
+
+ expect(document.activeElement).toBe(variantSelect);
+
+ fireEvent.click(within(dialog).getByRole('button', { name: /^cancel$/i }));
+ await expect(promise).resolves.toBeNull();
+ });
+
test('keeps apply enabled when semantic validation returns a warning', async () => {
const promise = openParamsEditorModal({
documentObj: document,
@@ -385,6 +773,17 @@ describe('params editor modal', () => {
expect(document.activeElement).toBe(trigger);
});
+ test('params editor stylesheet stacks table rows on narrow screens instead of forcing horizontal scroll', () => {
+ const css = readFileSync(
+ new URL('../../../js/gui_components/shared/test-data/ui/params-editor-modal.css', import.meta.url),
+ 'utf8'
+ );
+
+ expect(css).toContain('@media (max-width: 560px)');
+ expect(css).toContain('content: attr(data-label)');
+ expect(css).not.toContain('min-width: 720px');
+ });
+
test('shows a warning when existing params cannot be mapped to the documented fields', async () => {
const promise = openParamsEditorModal({
documentObj: document,
@@ -591,6 +990,46 @@ describe('params editor modal', () => {
await expect(promise).resolves.toBe('(abbreviated=false)');
});
+ test('leaves optional boolean unset without shifting later required params', async () => {
+ const promise = openParamsEditorModal({
+ documentObj: document,
+ windowObj: window,
+ commandLabel: 'internet.email',
+ helpModel: {
+ summary: 'Returns an email address.',
+ params: [
+ { name: 'commonOnly', type: 'boolean', optional: true },
+ { name: 'provider', type: 'string', optional: false },
+ ],
+ },
+ initialParams: '',
+ });
+
+ const dialog = within(getOverlay()).getByRole('dialog', { name: /edit params for internet\.email/i });
+ const unsetRadio = within(dialog).getByRole('radio', { name: /unset/i });
+ const providerInput = within(dialog).getByRole('textbox', { name: /provider value/i });
+ const applyButton = within(dialog).getByRole('button', { name: /^apply$/i });
+
+ expect(unsetRadio.checked).toBe(true);
+ expect(
+ within(dialog).getByText('()', {
+ selector: '[data-role="params-editor-preview"]',
+ })
+ ).toBeTruthy();
+ expect(applyButton.disabled).toBe(true);
+
+ fireEvent.input(providerInput, { target: { value: 'example.com' } });
+
+ expect(
+ within(dialog).getByText('(provider="example.com")', {
+ selector: '[data-role="params-editor-preview"]',
+ })
+ ).toBeTruthy();
+
+ fireEvent.click(applyButton);
+ await expect(promise).resolves.toBe('(provider="example.com")');
+ });
+
test('prefills required boolean params from existing values', async () => {
const promise = openParamsEditorModal({
documentObj: document,
diff --git a/packages/core/js/data_generation/enum/enumTestDataRuleValidator.js b/packages/core/js/data_generation/enum/enumTestDataRuleValidator.js
index 0af5fe8a..ddfdb52e 100644
--- a/packages/core/js/data_generation/enum/enumTestDataRuleValidator.js
+++ b/packages/core/js/data_generation/enum/enumTestDataRuleValidator.js
@@ -26,12 +26,6 @@ export class EnumTestDataRuleValidator {
return false;
}
- // Values must not be empty
- if (enumValues.some((v) => v.length === 0)) {
- this.validationError = 'Enum values cannot be empty';
- return false;
- }
-
return true;
} catch (err) {
this.validationError = err.message || String(err);
diff --git a/packages/core/js/data_generation/utils/enumParser.js b/packages/core/js/data_generation/utils/enumParser.js
index fe9e9a02..f9cc0701 100644
--- a/packages/core/js/data_generation/utils/enumParser.js
+++ b/packages/core/js/data_generation/utils/enumParser.js
@@ -110,8 +110,8 @@ export class EnumParser {
}
try {
- const values = this.parseCsvLiteral(spec);
- return values.length >= 2 && values.every((value) => value.length > 0);
+ const fields = this.parseCsvLiteralFields(spec);
+ return fields.length >= 2 && fields.every((field) => field.value.length > 0 || field.quoted);
} catch {
return false;
}
@@ -179,8 +179,9 @@ export class EnumParser {
if (allowImplicitCsv && spec.includes(',')) {
try {
- const values = this.parseCsvLiteral(spec);
- if (values.some((value) => value.length === 0)) {
+ const csvFields = this.parseCsvLiteralFields(spec);
+ const values = csvFields.map((field) => field.value);
+ if (csvFields.some((field) => field.value.length === 0 && !field.quoted)) {
return {
ok: false,
values: [],
@@ -269,7 +270,7 @@ export class EnumParser {
throw new Error(fragmentParsed.error);
}
}
- return this.buildCanonicalSchemaRuleSpecFromValues(this.parseCsvLiteral(displayValue));
+ return this.buildCanonicalSchemaRuleSpecFromValues(this.parseCsvEnumValues(displayValue));
}
static looksLikeEnumInvocationArgumentFragment(value) {
@@ -319,6 +320,10 @@ export class EnumParser {
}
static parseCsvLiteral(csvText) {
+ return this.parseCsvLiteralFields(csvText).map((field) => field.value);
+ }
+
+ static parseCsvLiteralFields(csvText) {
const text = String(csvText ?? '');
const values = [];
let currentValue = '';
@@ -346,7 +351,7 @@ export class EnumParser {
if (afterClosingQuote) {
if (char === ',') {
- values.push(currentValue);
+ values.push({ value: currentValue, quoted: true });
currentValue = '';
quotedField = false;
afterClosingQuote = false;
@@ -359,7 +364,7 @@ export class EnumParser {
}
if (char === ',') {
- values.push(quotedField ? currentValue : currentValue.trim());
+ values.push({ value: quotedField ? currentValue : currentValue.trim(), quoted: quotedField });
currentValue = '';
quotedField = false;
continue;
@@ -382,12 +387,15 @@ export class EnumParser {
throw new Error('Invalid enum CSV: unclosed quote');
}
- values.push(quotedField || afterClosingQuote ? currentValue : currentValue.trim());
+ values.push({
+ value: quotedField || afterClosingQuote ? currentValue : currentValue.trim(),
+ quoted: quotedField || afterClosingQuote,
+ });
return values;
}
- static validateEnumValueList(values, sourceName = 'values') {
+ static validateEnumValueList(values, sourceName = 'values', { allowEmptyStrings = false } = {}) {
if (!Array.isArray(values)) {
throw new Error(`Invalid keyword arguments: argument "${sourceName}" must be an array`);
}
@@ -397,14 +405,20 @@ export class EnumParser {
if (values.some((value) => typeof value !== 'string')) {
throw new Error(`Invalid keyword arguments: argument "${sourceName}" must contain only strings`);
}
- if (values.some((value) => value.length === 0)) {
+ if (!allowEmptyStrings && values.some((value) => value.length === 0)) {
throw new Error('Enum values cannot be empty');
}
return values;
}
static parseCsvEnumValues(csvText) {
- return this.validateEnumValueList(this.parseCsvLiteral(csvText), 'csv');
+ const fields = this.parseCsvLiteralFields(csvText);
+ const values = fields.map((field) => field.value);
+ this.validateEnumValueList(values, 'csv', { allowEmptyStrings: true });
+ if (fields.some((field) => field.value.length === 0 && !field.quoted)) {
+ throw new Error('Enum values cannot be empty');
+ }
+ return values;
}
static parseEnumFunctionValues(ruleSpec) {
@@ -447,9 +461,12 @@ export class EnumParser {
}
if (name === 'values') {
if (Array.isArray(argument.value)) {
- return this.validateEnumValueList(argument.value, 'values');
+ return this.validateEnumValueList(argument.value, 'values', { allowEmptyStrings: true });
}
if (typeof argument.value === 'string') {
+ if (!argument.value.includes(',')) {
+ return this.validateEnumValueList([argument.value], 'values', { allowEmptyStrings: true });
+ }
return this.parseCsvEnumValues(argument.value);
}
throw new Error('Invalid keyword arguments: argument "values" must be string or array');
@@ -461,15 +478,18 @@ export class EnumParser {
if (positionalValues.length === 1) {
const [value] = positionalValues;
if (Array.isArray(value)) {
- return this.validateEnumValueList(value, 'values');
+ return this.validateEnumValueList(value, 'values', { allowEmptyStrings: true });
}
if (typeof value === 'string') {
+ if (!value.includes(',')) {
+ return this.validateEnumValueList([value], 'values', { allowEmptyStrings: true });
+ }
return this.parseCsvEnumValues(value);
}
throw new Error('Invalid keyword arguments: enum values must be strings or an array of strings');
}
- return this.validateEnumValueList(positionalValues, 'values');
+ return this.validateEnumValueList(positionalValues, 'values', { allowEmptyStrings: true });
}
static splitEnumParameterValues(paramsStr) {
@@ -537,7 +557,7 @@ export class EnumParser {
}
// Simple comma-separated format
- return this.parseCsvLiteral(this.unwrapOptionalListParens(spec));
+ return this.parseCsvEnumValues(this.unwrapOptionalListParens(spec));
}
/**
diff --git a/packages/core/js/domain/domain-keywords.js b/packages/core/js/domain/domain-keywords.js
index 7d3a2e6c..c1b7631b 100644
--- a/packages/core/js/domain/domain-keywords.js
+++ b/packages/core/js/domain/domain-keywords.js
@@ -126,6 +126,9 @@ function buildDomainKeywordCatalog(definitions = DOMAIN_KEYWORD_DEFINITIONS) {
: {}),
description: String(arg?.description || '').trim(),
example: String(arg?.example || '').trim(),
+ allowedValues: Array.isArray(arg?.allowedValues) ? arg.allowedValues : [],
+ choices: Array.isArray(arg?.choices) ? arg.choices : [],
+ enumValues: Array.isArray(arg?.enumValues) ? arg.enumValues : [],
...(Object.prototype.hasOwnProperty.call(arg || {}, 'defaultValue')
? { defaultValue: arg.defaultValue }
: Object.prototype.hasOwnProperty.call(arg || {}, 'default')
@@ -273,12 +276,26 @@ function runFakerDelegate(target, fakerInstance, args = [], resultPath = '') {
return resolved;
}
-function isTypeMatch(value, typeName) {
- const raw = String(typeName || '').trim();
- if (!raw) {
+function getExpectedTypeTokens(specOrType) {
+ const rawType =
+ specOrType && typeof specOrType === 'object' ? String(specOrType?.type || '').trim() : String(specOrType || '');
+ if (specOrType && typeof specOrType === 'object' && rawType === 'enum' && Array.isArray(specOrType?.enumValues)) {
+ return specOrType.enumValues.map((entry) => String(entry).trim());
+ }
+ return rawType.split('|').map((entry) => entry.trim());
+}
+
+function isTypeMatch(value, specOrType) {
+ const allowed = getExpectedTypeTokens(specOrType);
+ const isExplicitEnumSpec =
+ specOrType &&
+ typeof specOrType === 'object' &&
+ String(specOrType?.type || '').trim() === 'enum' &&
+ Array.isArray(specOrType?.enumValues);
+ const raw = allowed.join('|').trim();
+ if ((!isExplicitEnumSpec && !raw) || (isExplicitEnumSpec && allowed.length === 0)) {
return false;
}
- const allowed = raw.split('|').map((entry) => entry.trim());
for (const item of allowed) {
if (/^[+-]?\d+(\.\d+)?$/.test(item) && typeof value === 'number' && Object.is(value, Number(item))) return true;
if (item === 'string' && typeof value === 'string') return true;
@@ -321,14 +338,20 @@ function normalizeLiteralTypeToken(typeToken) {
if (isQuotedLiteralTypeToken(typeToken)) {
return JSON.stringify(unquoteLiteralTypeToken(typeToken));
}
+ if (typeToken === '' || /^[^\w\s]+$/.test(typeToken)) {
+ return JSON.stringify(typeToken);
+ }
return typeToken;
}
-function formatExpectedType(typeName) {
- const allowed = String(typeName || '')
- .split('|')
- .map((entry) => entry.trim())
- .filter(Boolean)
+function formatExpectedType(specOrType) {
+ const isExplicitEnumSpec =
+ specOrType &&
+ typeof specOrType === 'object' &&
+ String(specOrType?.type || '').trim() === 'enum' &&
+ Array.isArray(specOrType?.enumValues);
+ const allowed = getExpectedTypeTokens(specOrType)
+ .filter((entry) => isExplicitEnumSpec || Boolean(entry))
.map((entry) => normalizeLiteralTypeToken(entry));
if (allowed.length === 0) {
@@ -527,10 +550,21 @@ function createRequiredArgError(spec) {
};
}
+function describeMismatchedArgValue(spec, value) {
+ const isExplicitEnumSpec =
+ spec && typeof spec === 'object' && String(spec?.type || '').trim() === 'enum' && Array.isArray(spec?.enumValues);
+
+ if (isExplicitEnumSpec && (value === null || ['string', 'number', 'boolean'].includes(typeof value))) {
+ return JSON.stringify(value);
+ }
+
+ return describeValueType(value);
+}
+
function createTypeMismatchArgError(spec, value) {
return {
ok: false,
- error: `Invalid keyword arguments: argument "${spec.name}" must be ${formatExpectedType(spec.type)}, not ${describeValueType(value)}`,
+ error: `Invalid keyword arguments: argument "${spec.name}" must be ${formatExpectedType(spec)}, not ${describeMismatchedArgValue(spec, value)}`,
};
}
@@ -546,7 +580,7 @@ function validateSingleKeywordArg(spec, value, argsByName) {
if (spec.required && typeof value === 'undefined') {
return createRequiredArgError(spec);
}
- if (typeof value !== 'undefined' && !isTypeMatch(value, spec.type)) {
+ if (typeof value !== 'undefined' && !isTypeMatch(value, spec)) {
return createTypeMismatchArgError(spec, value);
}
@@ -574,7 +608,7 @@ function validateVariadicKeywordArgs(schema, argumentList, argsByName, variadicI
}
for (const value of variadicValues) {
- if (typeof value !== 'undefined' && !isTypeMatch(value, variadicSpec.type)) {
+ if (typeof value !== 'undefined' && !isTypeMatch(value, variadicSpec)) {
return createTypeMismatchArgError(variadicSpec, value);
}
}
diff --git a/packages/core/js/faker/faker-helper-keyword-definitions.js b/packages/core/js/faker/faker-helper-keyword-definitions.js
index 0960e157..5d08185d 100644
--- a/packages/core/js/faker/faker-helper-keyword-definitions.js
+++ b/packages/core/js/faker/faker-helper-keyword-definitions.js
@@ -58,6 +58,9 @@ function normalizeFakerHelperKeywordHelp(definition) {
type: String(param.type || '').trim(),
description: String(param.description || '').trim(),
examples: Array.isArray(param.examples) ? param.examples : [],
+ allowedValues: Array.isArray(param.allowedValues) ? param.allowedValues : [],
+ choices: Array.isArray(param.choices) ? param.choices : [],
+ enumValues: Array.isArray(param.enumValues) ? param.enumValues : [],
}))
: [];
const returnType = String(definition.returnType || '').trim();
@@ -107,6 +110,9 @@ function mapDomainKeywordHelpToFakerCommandHelp(commandHelp) {
type: arg.type,
description: arg.description || '',
examples: Array.isArray(arg.examples) ? arg.examples : [],
+ allowedValues: Array.isArray(arg.allowedValues) ? arg.allowedValues : [],
+ choices: Array.isArray(arg.choices) ? arg.choices : [],
+ enumValues: Array.isArray(arg.enumValues) ? arg.enumValues : [],
...(Object.prototype.hasOwnProperty.call(arg || {}, 'usageExampleSupported')
? { usageExampleSupported: arg.usageExampleSupported !== false }
: {}),
diff --git a/packages/core/js/keywords/domain/airline/seat-keyword-definition.js b/packages/core/js/keywords/domain/airline/seat-keyword-definition.js
index 0197662a..38374cf2 100644
--- a/packages/core/js/keywords/domain/airline/seat-keyword-definition.js
+++ b/packages/core/js/keywords/domain/airline/seat-keyword-definition.js
@@ -2,7 +2,7 @@ import { validateAirlineSeatValue } from '../../../command-help/command-help-val
const AIRCRAFT_TYPES = ['narrowbody', 'regional', 'widebody'];
-const AIRCRAFT_TYPE_RETURN_TYPE = AIRCRAFT_TYPES.join('|');
+const AIRCRAFT_TYPE_RETURN_TYPE = AIRCRAFT_TYPES;
const AIRLINE_SEAT_KEYWORD_DEFINITION = {
keyword: 'airline.seat',
@@ -37,7 +37,8 @@ const AIRLINE_SEAT_KEYWORD_DEFINITION = {
args: [
{
name: 'aircraftType',
- type: AIRCRAFT_TYPE_RETURN_TYPE,
+ type: 'enum',
+ enumValues: AIRCRAFT_TYPE_RETURN_TYPE,
required: false,
description: 'The aircraft type. Can be one of narrowbody, regional, widebody.',
examples: ['widebody'],
diff --git a/packages/core/js/keywords/domain/autoincrement/timestamp-keyword-definition.js b/packages/core/js/keywords/domain/autoincrement/timestamp-keyword-definition.js
index 80d3dd6c..846a1089 100644
--- a/packages/core/js/keywords/domain/autoincrement/timestamp-keyword-definition.js
+++ b/packages/core/js/keywords/domain/autoincrement/timestamp-keyword-definition.js
@@ -1,5 +1,16 @@
import { validateStringValue } from '../../../command-help/command-help-validators.js';
+const AUTO_INCREMENT_TIMESTAMP_STEP_TYPES = [
+ 'milliseconds',
+ 'seconds',
+ 'minutes',
+ 'hours',
+ 'days',
+ 'weeks',
+ 'months',
+ 'years',
+];
+
const AUTO_INCREMENT_TIMESTAMP_KEYWORD_DEFINITION = {
keyword: 'autoIncrement.timestamp',
delegate: {
@@ -84,7 +95,8 @@ const AUTO_INCREMENT_TIMESTAMP_KEYWORD_DEFINITION = {
},
{
name: 'type',
- type: 'string',
+ type: 'enum',
+ enumValues: AUTO_INCREMENT_TIMESTAMP_STEP_TYPES,
required: false,
description:
'Unit applied to step for each row. Supports milliseconds, seconds, minutes, hours, days, weeks, months, or years. Defaults to seconds.',
diff --git a/packages/core/js/keywords/domain/color/cmyk-keyword-definition.js b/packages/core/js/keywords/domain/color/cmyk-keyword-definition.js
index 8dcfe47c..6a542870 100644
--- a/packages/core/js/keywords/domain/color/cmyk-keyword-definition.js
+++ b/packages/core/js/keywords/domain/color/cmyk-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateArrayOrStringValue } from '../../../command-help/command-help-validators.js';
-const COLOR_FORMAT_TYPE = 'decimal|css|binary';
+const COLOR_FORMAT_TYPE = ['decimal', 'css', 'binary'];
const COLOR_CMYK_KEYWORD_DEFINITION = {
keyword: 'color.cmyk',
@@ -30,7 +30,8 @@ const COLOR_CMYK_KEYWORD_DEFINITION = {
args: [
{
name: 'format',
- type: COLOR_FORMAT_TYPE,
+ type: 'enum',
+ enumValues: COLOR_FORMAT_TYPE,
required: false,
description: 'Format of generated CMYK color.',
},
diff --git a/packages/core/js/keywords/domain/color/color-by-csscolor-space-keyword-definition.js b/packages/core/js/keywords/domain/color/color-by-csscolor-space-keyword-definition.js
index 128a1e69..a2cf29f4 100644
--- a/packages/core/js/keywords/domain/color/color-by-csscolor-space-keyword-definition.js
+++ b/packages/core/js/keywords/domain/color/color-by-csscolor-space-keyword-definition.js
@@ -1,8 +1,8 @@
import { validateArrayOrStringValue } from '../../../command-help/command-help-validators.js';
-const COLOR_FORMAT_TYPE = 'decimal|css|binary';
+const COLOR_FORMAT_TYPE = ['decimal', 'css', 'binary'];
-const CSS_SUPPORTED_SPACE_RETURN_TYPE = 'sRGB|display-p3|rec2020|a98-rgb|prophoto-rgb';
+const CSS_SUPPORTED_SPACE_RETURN_TYPE = ['sRGB', 'display-p3', 'rec2020', 'a98-rgb', 'prophoto-rgb'];
const COLOR_COLOR_BY_CSSCOLOR_SPACE_KEYWORD_DEFINITION = {
keyword: 'color.colorByCSSColorSpace',
@@ -37,13 +37,15 @@ const COLOR_COLOR_BY_CSSCOLOR_SPACE_KEYWORD_DEFINITION = {
args: [
{
name: 'format',
- type: COLOR_FORMAT_TYPE,
+ type: 'enum',
+ enumValues: COLOR_FORMAT_TYPE,
required: false,
description: 'Format of generated RGB color.',
},
{
name: 'space',
- type: CSS_SUPPORTED_SPACE_RETURN_TYPE,
+ type: 'enum',
+ enumValues: CSS_SUPPORTED_SPACE_RETURN_TYPE,
required: false,
description: 'Color space to generate the color for.',
},
diff --git a/packages/core/js/keywords/domain/color/hsl-keyword-definition.js b/packages/core/js/keywords/domain/color/hsl-keyword-definition.js
index aa906b70..b32a2164 100644
--- a/packages/core/js/keywords/domain/color/hsl-keyword-definition.js
+++ b/packages/core/js/keywords/domain/color/hsl-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateArrayOrStringValue } from '../../../command-help/command-help-validators.js';
-const COLOR_FORMAT_TYPE = 'decimal|css|binary';
+const COLOR_FORMAT_TYPE = ['decimal', 'css', 'binary'];
const COLOR_HSL_KEYWORD_DEFINITION = {
keyword: 'color.hsl',
@@ -40,7 +40,8 @@ const COLOR_HSL_KEYWORD_DEFINITION = {
args: [
{
name: 'format',
- type: COLOR_FORMAT_TYPE,
+ type: 'enum',
+ enumValues: COLOR_FORMAT_TYPE,
required: false,
description: 'Format of generated HSL color.',
},
diff --git a/packages/core/js/keywords/domain/color/hwb-keyword-definition.js b/packages/core/js/keywords/domain/color/hwb-keyword-definition.js
index bece6b99..1ae62620 100644
--- a/packages/core/js/keywords/domain/color/hwb-keyword-definition.js
+++ b/packages/core/js/keywords/domain/color/hwb-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateArrayOrStringValue } from '../../../command-help/command-help-validators.js';
-const COLOR_FORMAT_TYPE = 'decimal|css|binary';
+const COLOR_FORMAT_TYPE = ['decimal', 'css', 'binary'];
const COLOR_HWB_KEYWORD_DEFINITION = {
keyword: 'color.hwb',
@@ -30,7 +30,8 @@ const COLOR_HWB_KEYWORD_DEFINITION = {
args: [
{
name: 'format',
- type: COLOR_FORMAT_TYPE,
+ type: 'enum',
+ enumValues: COLOR_FORMAT_TYPE,
required: false,
description: 'Format of generated RGB color.',
},
diff --git a/packages/core/js/keywords/domain/color/lab-keyword-definition.js b/packages/core/js/keywords/domain/color/lab-keyword-definition.js
index 1b13a712..4cabcc1c 100644
--- a/packages/core/js/keywords/domain/color/lab-keyword-definition.js
+++ b/packages/core/js/keywords/domain/color/lab-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateArrayOrStringValue } from '../../../command-help/command-help-validators.js';
-const COLOR_FORMAT_TYPE = 'decimal|css|binary';
+const COLOR_FORMAT_TYPE = ['decimal', 'css', 'binary'];
const COLOR_LAB_KEYWORD_DEFINITION = {
keyword: 'color.lab',
@@ -30,7 +30,8 @@ const COLOR_LAB_KEYWORD_DEFINITION = {
args: [
{
name: 'format',
- type: COLOR_FORMAT_TYPE,
+ type: 'enum',
+ enumValues: COLOR_FORMAT_TYPE,
required: false,
description: 'Format of generated RGB color.',
},
diff --git a/packages/core/js/keywords/domain/color/lch-keyword-definition.js b/packages/core/js/keywords/domain/color/lch-keyword-definition.js
index 5800c8a3..9207418f 100644
--- a/packages/core/js/keywords/domain/color/lch-keyword-definition.js
+++ b/packages/core/js/keywords/domain/color/lch-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateArrayOrStringValue } from '../../../command-help/command-help-validators.js';
-const COLOR_FORMAT_TYPE = 'decimal|css|binary';
+const COLOR_FORMAT_TYPE = ['decimal', 'css', 'binary'];
const COLOR_LCH_KEYWORD_DEFINITION = {
keyword: 'color.lch',
@@ -30,7 +30,8 @@ const COLOR_LCH_KEYWORD_DEFINITION = {
args: [
{
name: 'format',
- type: COLOR_FORMAT_TYPE,
+ type: 'enum',
+ enumValues: COLOR_FORMAT_TYPE,
required: false,
description: 'Format of generated RGB color.',
},
diff --git a/packages/core/js/keywords/domain/color/rgb-keyword-definition.js b/packages/core/js/keywords/domain/color/rgb-keyword-definition.js
index a5e56b9d..9789e55e 100644
--- a/packages/core/js/keywords/domain/color/rgb-keyword-definition.js
+++ b/packages/core/js/keywords/domain/color/rgb-keyword-definition.js
@@ -1,8 +1,8 @@
import { validateArrayOrStringValue } from '../../../command-help/command-help-validators.js';
-const COLOR_RGB_FORMAT_TYPE = 'hex|decimal|css|binary';
+const COLOR_RGB_FORMAT_TYPE = ['hex', 'decimal', 'css', 'binary'];
-const COLOR_CASING_TYPE = 'lower|upper|mixed';
+const COLOR_CASING_TYPE = ['lower', 'upper', 'mixed'];
const COLOR_RGB_KEYWORD_DEFINITION = {
keyword: 'color.rgb',
@@ -47,14 +47,16 @@ const COLOR_RGB_KEYWORD_DEFINITION = {
args: [
{
name: 'casing',
- type: COLOR_CASING_TYPE,
+ type: 'enum',
+ enumValues: COLOR_CASING_TYPE,
required: false,
description: "Letter type case of the generated hex color. Only applied when 'hex' format is used.",
examples: ['upper'],
},
{
name: 'format',
- type: COLOR_RGB_FORMAT_TYPE,
+ type: 'enum',
+ enumValues: COLOR_RGB_FORMAT_TYPE,
required: false,
description: 'Format of generated RGB color.',
examples: ['hex'],
diff --git a/packages/core/js/keywords/domain/commerce/isbn-keyword-definition.js b/packages/core/js/keywords/domain/commerce/isbn-keyword-definition.js
index d560e2c9..9affff89 100644
--- a/packages/core/js/keywords/domain/commerce/isbn-keyword-definition.js
+++ b/packages/core/js/keywords/domain/commerce/isbn-keyword-definition.js
@@ -39,7 +39,8 @@ const COMMERCE_ISBN_KEYWORD_DEFINITION = {
},
{
name: 'variant',
- type: '10|13',
+ type: 'enum',
+ enumValues: ['10', '13'],
required: false,
description: 'ISBN length variant: use 10 for ISBN-10 or 13 for ISBN-13.',
},
diff --git a/packages/core/js/keywords/domain/datatype/datatype-enum.js b/packages/core/js/keywords/domain/datatype/datatype-enum.js
index 88447385..2dd53fd3 100644
--- a/packages/core/js/keywords/domain/datatype/datatype-enum.js
+++ b/packages/core/js/keywords/domain/datatype/datatype-enum.js
@@ -3,22 +3,22 @@ import { EnumParser } from '../../../data_generation/utils/enumParser.js';
function normalizeDatatypeEnumValuesFromArgs(args = []) {
const rawArgs = Array.isArray(args) ? args : [];
if (rawArgs.length === 1 && Array.isArray(rawArgs[0])) {
- return rawArgs[0].map((value) => value);
+ return EnumParser.validateEnumValueList(rawArgs[0], 'values', { allowEmptyStrings: true }).map((value) => value);
}
if (rawArgs.length === 1 && typeof rawArgs[0] === 'string') {
- const singleValue = rawArgs[0].trim();
- if (singleValue.length === 0) {
- return [];
- }
-
- try {
- return EnumParser.extractEnumValues(singleValue);
- } catch {
+ const singleValue = rawArgs[0];
+ if (!singleValue.includes(',')) {
return [singleValue];
}
+
+ return EnumParser.parseCsvEnumValues(singleValue);
}
- return rawArgs.flatMap((value) => (Array.isArray(value) ? value : [value]));
+ return EnumParser.validateEnumValueList(
+ rawArgs.flatMap((value) => (Array.isArray(value) ? value : [value])),
+ 'values',
+ { allowEmptyStrings: true }
+ );
}
function normalizeDatatypeEnumArgs(args = []) {
diff --git a/packages/core/js/keywords/domain/datatype/enum-keyword-definition.js b/packages/core/js/keywords/domain/datatype/enum-keyword-definition.js
index f79bf3b6..6cdb36ab 100644
--- a/packages/core/js/keywords/domain/datatype/enum-keyword-definition.js
+++ b/packages/core/js/keywords/domain/datatype/enum-keyword-definition.js
@@ -2,19 +2,24 @@ import { validateEnumMemberValue } from '../../../command-help/command-help-vali
import { normalizeDatatypeEnumArgs } from './datatype-enum.js';
function validateDatatypeEnumArgs(args = []) {
- const values = normalizeDatatypeEnumArgs(args).map((value) => String(value));
-
- if (values.length === 0) {
+ let values = [];
+ try {
+ values = normalizeDatatypeEnumArgs(args).map((value) => String(value));
+ } catch (error) {
+ const message = String(error?.message || error || '').trim();
return {
ok: false,
- error: 'Invalid keyword arguments: argument "values" is required',
+ error:
+ message === 'Enum values cannot be empty'
+ ? 'Invalid keyword arguments: enum values cannot be empty'
+ : message || 'Invalid keyword arguments',
};
}
- if (values.some((value) => value.length === 0)) {
+ if (values.length === 0) {
return {
ok: false,
- error: 'Invalid keyword arguments: enum values cannot be empty',
+ error: 'Invalid keyword arguments: argument "values" is required',
};
}
diff --git a/packages/core/js/keywords/domain/date/birthdate-keyword-definition.js b/packages/core/js/keywords/domain/date/birthdate-keyword-definition.js
index ce8d13b1..16128f32 100644
--- a/packages/core/js/keywords/domain/date/birthdate-keyword-definition.js
+++ b/packages/core/js/keywords/domain/date/birthdate-keyword-definition.js
@@ -74,7 +74,8 @@ const DATE_BIRTHDATE_KEYWORD_DEFINITION = {
},
{
name: 'mode',
- type: 'age|year',
+ type: 'enum',
+ enumValues: ['age', 'year'],
required: false,
description: "Either 'age' or 'year' to generate a birthdate based on the age or year range.",
examples: ['age'],
diff --git a/packages/core/js/keywords/domain/finance/bitcoin-address-keyword-definition.js b/packages/core/js/keywords/domain/finance/bitcoin-address-keyword-definition.js
index fa85337e..8be91f59 100644
--- a/packages/core/js/keywords/domain/finance/bitcoin-address-keyword-definition.js
+++ b/packages/core/js/keywords/domain/finance/bitcoin-address-keyword-definition.js
@@ -1,8 +1,8 @@
import { validateBitcoinAddressValue } from '../../../command-help/command-help-validators.js';
-const BITCOIN_ADDRESS_TYPE = 'legacy|segwit|bech32|taproot';
+const BITCOIN_ADDRESS_TYPE = ['legacy', 'segwit', 'bech32', 'taproot'];
-const BITCOIN_NETWORK_TYPE = 'mainnet|testnet';
+const BITCOIN_NETWORK_TYPE = ['mainnet', 'testnet'];
const FINANCE_BITCOIN_ADDRESS_KEYWORD_DEFINITION = {
keyword: 'finance.bitcoinAddress',
@@ -37,14 +37,16 @@ const FINANCE_BITCOIN_ADDRESS_KEYWORD_DEFINITION = {
args: [
{
name: 'type',
- type: BITCOIN_ADDRESS_TYPE,
+ type: 'enum',
+ enumValues: BITCOIN_ADDRESS_TYPE,
required: false,
description: "The bitcoin address type ('legacy', 'segwit', 'bech32' or 'taproot').",
examples: ['bech32'],
},
{
name: 'network',
- type: BITCOIN_NETWORK_TYPE,
+ type: 'enum',
+ enumValues: BITCOIN_NETWORK_TYPE,
required: false,
description: "The bitcoin network ('mainnet' or 'testnet').",
examples: ['testnet'],
diff --git a/packages/core/js/keywords/domain/internet/ipv4-keyword-definition.js b/packages/core/js/keywords/domain/internet/ipv4-keyword-definition.js
index 658aecf4..fb277dc0 100644
--- a/packages/core/js/keywords/domain/internet/ipv4-keyword-definition.js
+++ b/packages/core/js/keywords/domain/internet/ipv4-keyword-definition.js
@@ -1,7 +1,17 @@
import { validateIpv4Value } from '../../../command-help/command-help-validators.js';
-const IPV4_NETWORK_TYPE =
- 'any|loopback|private-a|private-b|private-c|test-net-1|test-net-2|test-net-3|link-local|multicast';
+const IPV4_NETWORK_TYPE = [
+ 'any',
+ 'loopback',
+ 'private-a',
+ 'private-b',
+ 'private-c',
+ 'test-net-1',
+ 'test-net-2',
+ 'test-net-3',
+ 'link-local',
+ 'multicast',
+];
const INTERNET_IPV4_KEYWORD_DEFINITION = {
keyword: 'internet.ipv4',
@@ -43,7 +53,8 @@ const INTERNET_IPV4_KEYWORD_DEFINITION = {
},
{
name: 'network',
- type: IPV4_NETWORK_TYPE,
+ type: 'enum',
+ enumValues: IPV4_NETWORK_TYPE,
required: false,
description: 'The optional network to use. This is intended as an alias for well-known cidrBlocks.',
examples: ['private-a'],
diff --git a/packages/core/js/keywords/domain/internet/mac-keyword-definition.js b/packages/core/js/keywords/domain/internet/mac-keyword-definition.js
index a0821ae1..825668b1 100644
--- a/packages/core/js/keywords/domain/internet/mac-keyword-definition.js
+++ b/packages/core/js/keywords/domain/internet/mac-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateMacAddressValue } from '../../../command-help/command-help-validators.js';
-const MAC_SEPARATOR_TYPE = '":"|"-"|""';
+const MAC_SEPARATOR_TYPE = [':', '-', ''];
const INTERNET_MAC_KEYWORD_DEFINITION = {
keyword: 'internet.mac',
@@ -30,7 +30,8 @@ const INTERNET_MAC_KEYWORD_DEFINITION = {
args: [
{
name: 'separator',
- type: MAC_SEPARATOR_TYPE,
+ type: 'enum',
+ enumValues: MAC_SEPARATOR_TYPE,
required: false,
description: "The optional separator to use. Can be either ':', '-' or ''.",
},
diff --git a/packages/core/js/keywords/domain/internet/url-keyword-definition.js b/packages/core/js/keywords/domain/internet/url-keyword-definition.js
index 804a2ce4..7f0bae26 100644
--- a/packages/core/js/keywords/domain/internet/url-keyword-definition.js
+++ b/packages/core/js/keywords/domain/internet/url-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateUrlValue } from '../../../command-help/command-help-validators.js';
-const HTTP_PROTOCOL_RETURN_TYPE = 'http|https';
+const HTTP_PROTOCOL_RETURN_TYPE = ['http', 'https'];
const INTERNET_URL_KEYWORD_DEFINITION = {
keyword: 'internet.url',
@@ -41,7 +41,8 @@ const INTERNET_URL_KEYWORD_DEFINITION = {
},
{
name: 'protocol',
- type: HTTP_PROTOCOL_RETURN_TYPE,
+ type: 'enum',
+ enumValues: HTTP_PROTOCOL_RETURN_TYPE,
required: false,
description: 'The protocol to use.',
},
diff --git a/packages/core/js/keywords/domain/location/country-code-keyword-definition.js b/packages/core/js/keywords/domain/location/country-code-keyword-definition.js
index fc3b2b14..901cabf9 100644
--- a/packages/core/js/keywords/domain/location/country-code-keyword-definition.js
+++ b/packages/core/js/keywords/domain/location/country-code-keyword-definition.js
@@ -28,7 +28,8 @@ const LOCATION_COUNTRY_CODE_KEYWORD_DEFINITION = {
args: [
{
name: 'variant',
- type: 'alpha-2|alpha-3|numeric',
+ type: 'enum',
+ enumValues: ['alpha-2', 'alpha-3', 'numeric'],
required: false,
description:
"The code to return. Can be either 'alpha-2' (two-letter code), 'alpha-3' (three-letter code) or 'numeric' (numeric code).",
diff --git a/packages/core/js/keywords/domain/lorem/word-keyword-definition.js b/packages/core/js/keywords/domain/lorem/word-keyword-definition.js
index bf81c42c..9caaf069 100644
--- a/packages/core/js/keywords/domain/lorem/word-keyword-definition.js
+++ b/packages/core/js/keywords/domain/lorem/word-keyword-definition.js
@@ -1,7 +1,7 @@
import { validateStringValue } from '../../../command-help/command-help-validators.js';
import { createPositiveIntegerArgsValidator } from '../shared/common-arg-validators.js';
-const LOREM_WORD_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length';
+const LOREM_WORD_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length'];
const validateLoremWordArgs = createPositiveIntegerArgsValidator(['length']);
const LOREM_WORD_KEYWORD_DEFINITION = {
@@ -44,7 +44,8 @@ const LOREM_WORD_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: LOREM_WORD_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: LOREM_WORD_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/js/keywords/domain/person/first-name-keyword-definition.js b/packages/core/js/keywords/domain/person/first-name-keyword-definition.js
index 606ca1af..c0ed5b0f 100644
--- a/packages/core/js/keywords/domain/person/first-name-keyword-definition.js
+++ b/packages/core/js/keywords/domain/person/first-name-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateStringValue } from '../../../command-help/command-help-validators.js';
-const PERSON_SEX_TYPE = 'female|male';
+const PERSON_SEX_TYPE = ['female', 'male'];
const PERSON_FIRST_NAME_KEYWORD_DEFINITION = {
keyword: 'person.firstName',
@@ -29,7 +29,8 @@ const PERSON_FIRST_NAME_KEYWORD_DEFINITION = {
args: [
{
name: 'sex',
- type: PERSON_SEX_TYPE,
+ type: 'enum',
+ enumValues: PERSON_SEX_TYPE,
required: false,
description: 'Optional sex for first-name selection. Valid values: female or male.',
},
diff --git a/packages/core/js/keywords/domain/person/last-name-keyword-definition.js b/packages/core/js/keywords/domain/person/last-name-keyword-definition.js
index ba5b4bb3..f74b9943 100644
--- a/packages/core/js/keywords/domain/person/last-name-keyword-definition.js
+++ b/packages/core/js/keywords/domain/person/last-name-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateStringValue } from '../../../command-help/command-help-validators.js';
-const PERSON_SEX_TYPE = 'female|male';
+const PERSON_SEX_TYPE = ['female', 'male'];
const PERSON_LAST_NAME_KEYWORD_DEFINITION = {
keyword: 'person.lastName',
@@ -29,7 +29,8 @@ const PERSON_LAST_NAME_KEYWORD_DEFINITION = {
args: [
{
name: 'sex',
- type: PERSON_SEX_TYPE,
+ type: 'enum',
+ enumValues: PERSON_SEX_TYPE,
required: false,
description: 'Optional sex for last-name selection. Valid values: female or male.',
},
diff --git a/packages/core/js/keywords/domain/person/middle-name-keyword-definition.js b/packages/core/js/keywords/domain/person/middle-name-keyword-definition.js
index 05fa2fee..a7f85ebf 100644
--- a/packages/core/js/keywords/domain/person/middle-name-keyword-definition.js
+++ b/packages/core/js/keywords/domain/person/middle-name-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateStringValue } from '../../../command-help/command-help-validators.js';
-const PERSON_SEX_TYPE = 'female|male';
+const PERSON_SEX_TYPE = ['female', 'male'];
const PERSON_MIDDLE_NAME_KEYWORD_DEFINITION = {
keyword: 'person.middleName',
@@ -29,7 +29,8 @@ const PERSON_MIDDLE_NAME_KEYWORD_DEFINITION = {
args: [
{
name: 'sex',
- type: PERSON_SEX_TYPE,
+ type: 'enum',
+ enumValues: PERSON_SEX_TYPE,
required: false,
description: 'Optional sex for middle-name selection. Valid values: female or male.',
},
diff --git a/packages/core/js/keywords/domain/person/prefix-keyword-definition.js b/packages/core/js/keywords/domain/person/prefix-keyword-definition.js
index 579f48fb..cd024857 100644
--- a/packages/core/js/keywords/domain/person/prefix-keyword-definition.js
+++ b/packages/core/js/keywords/domain/person/prefix-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateStringValue } from '../../../command-help/command-help-validators.js';
-const PERSON_SEX_TYPE = 'female|male';
+const PERSON_SEX_TYPE = ['female', 'male'];
const PERSON_PREFIX_KEYWORD_DEFINITION = {
keyword: 'person.prefix',
@@ -29,7 +29,8 @@ const PERSON_PREFIX_KEYWORD_DEFINITION = {
args: [
{
name: 'sex',
- type: PERSON_SEX_TYPE,
+ type: 'enum',
+ enumValues: PERSON_SEX_TYPE,
required: false,
description: "The optional sex to use. Can be either 'female' or 'male'.",
},
diff --git a/packages/core/js/keywords/domain/phone/number-keyword-definition.js b/packages/core/js/keywords/domain/phone/number-keyword-definition.js
index 7ecce5ae..0eb9b5d3 100644
--- a/packages/core/js/keywords/domain/phone/number-keyword-definition.js
+++ b/packages/core/js/keywords/domain/phone/number-keyword-definition.js
@@ -1,6 +1,6 @@
import { validateStringValue } from '../../../command-help/command-help-validators.js';
-const PHONE_NUMBER_STYLE_TYPE = 'human|national|international';
+const PHONE_NUMBER_STYLE_TYPE = ['human', 'national', 'international'];
const PHONE_NUMBER_KEYWORD_DEFINITION = {
keyword: 'phone.number',
@@ -30,7 +30,8 @@ const PHONE_NUMBER_KEYWORD_DEFINITION = {
args: [
{
name: 'style',
- type: PHONE_NUMBER_STYLE_TYPE,
+ type: 'enum',
+ enumValues: PHONE_NUMBER_STYLE_TYPE,
required: false,
description:
"Style of the generated phone number: 'human': (default) A human-input phone number, e.g. 555-770-7727 or 555.770.7727 x1234 'national': A phone number in a standardized national format, e.g. (555) 123-4567. 'international': A phone number in the E.123 international format, e.g. +15551234567",
diff --git a/packages/core/js/keywords/domain/shared/common-arg-validators.js b/packages/core/js/keywords/domain/shared/common-arg-validators.js
index c945b16e..ab02872b 100644
--- a/packages/core/js/keywords/domain/shared/common-arg-validators.js
+++ b/packages/core/js/keywords/domain/shared/common-arg-validators.js
@@ -13,7 +13,7 @@ function createPositiveIntegerArgsValidator(argNames = []) {
);
}
-const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length';
+const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length'];
function createWordSelectionArgsValidator() {
return createPositiveIntegerArgsValidator(['length']);
diff --git a/packages/core/js/keywords/domain/string/alpha-keyword-definition.js b/packages/core/js/keywords/domain/string/alpha-keyword-definition.js
index a4692668..cc25f006 100644
--- a/packages/core/js/keywords/domain/string/alpha-keyword-definition.js
+++ b/packages/core/js/keywords/domain/string/alpha-keyword-definition.js
@@ -1,7 +1,7 @@
import { validateAlphaStringValue } from '../../../command-help/command-help-validators.js';
import { createPositiveIntegerArgsValidator } from '../shared/common-arg-validators.js';
-const STRING_CASING_TYPE = 'upper|lower|mixed';
+const STRING_CASING_TYPE = ['upper', 'lower', 'mixed'];
const validateStringAlphaArgs = createPositiveIntegerArgsValidator(['length']);
const STRING_ALPHA_KEYWORD_DEFINITION = {
@@ -59,7 +59,8 @@ const STRING_ALPHA_KEYWORD_DEFINITION = {
},
{
name: 'casing',
- type: STRING_CASING_TYPE,
+ type: 'enum',
+ enumValues: STRING_CASING_TYPE,
required: false,
description: 'The casing of the characters.',
},
diff --git a/packages/core/js/keywords/domain/string/alphanumeric-keyword-definition.js b/packages/core/js/keywords/domain/string/alphanumeric-keyword-definition.js
index 121b3a3c..942751c5 100644
--- a/packages/core/js/keywords/domain/string/alphanumeric-keyword-definition.js
+++ b/packages/core/js/keywords/domain/string/alphanumeric-keyword-definition.js
@@ -1,7 +1,7 @@
import { validateAlphanumericStringValue } from '../../../command-help/command-help-validators.js';
import { createPositiveIntegerArgsValidator } from '../shared/common-arg-validators.js';
-const STRING_CASING_TYPE = 'upper|lower|mixed';
+const STRING_CASING_TYPE = ['upper', 'lower', 'mixed'];
const validateStringAlphanumericArgs = createPositiveIntegerArgsValidator(['length']);
const STRING_ALPHANUMERIC_KEYWORD_DEFINITION = {
@@ -49,7 +49,8 @@ const STRING_ALPHANUMERIC_KEYWORD_DEFINITION = {
},
{
name: 'casing',
- type: STRING_CASING_TYPE,
+ type: 'enum',
+ enumValues: STRING_CASING_TYPE,
required: false,
description: 'The casing of the characters.',
},
diff --git a/packages/core/js/keywords/domain/string/hexadecimal-keyword-definition.js b/packages/core/js/keywords/domain/string/hexadecimal-keyword-definition.js
index 85e4d476..8e5d163b 100644
--- a/packages/core/js/keywords/domain/string/hexadecimal-keyword-definition.js
+++ b/packages/core/js/keywords/domain/string/hexadecimal-keyword-definition.js
@@ -1,7 +1,7 @@
import { validateHexadecimalStringValue } from '../../../command-help/command-help-validators.js';
import { createPositiveIntegerArgsValidator } from '../shared/common-arg-validators.js';
-const STRING_CASING_TYPE = 'upper|lower|mixed';
+const STRING_CASING_TYPE = ['upper', 'lower', 'mixed'];
const validateStringHexadecimalArgs = createPositiveIntegerArgsValidator(['length']);
const STRING_HEXADECIMAL_KEYWORD_DEFINITION = {
@@ -43,7 +43,8 @@ const STRING_HEXADECIMAL_KEYWORD_DEFINITION = {
args: [
{
name: 'casing',
- type: STRING_CASING_TYPE,
+ type: 'enum',
+ enumValues: STRING_CASING_TYPE,
required: false,
description: 'Casing of the generated number.',
},
diff --git a/packages/core/js/keywords/domain/string/uuid-keyword-definition.js b/packages/core/js/keywords/domain/string/uuid-keyword-definition.js
index 6dcd85b6..43a866ce 100644
--- a/packages/core/js/keywords/domain/string/uuid-keyword-definition.js
+++ b/packages/core/js/keywords/domain/string/uuid-keyword-definition.js
@@ -33,7 +33,8 @@ const STRING_UUID_KEYWORD_DEFINITION = {
args: [
{
name: 'version',
- type: '4|7',
+ type: 'enum',
+ enumValues: ['4', '7'],
required: false,
description:
'The specific UUID version to use. If refDate is supplied and version is omitted, version 7 is used automatically.',
diff --git a/packages/core/js/keywords/domain/word/adjective-keyword-definition.js b/packages/core/js/keywords/domain/word/adjective-keyword-definition.js
index bb9d31c9..0a887dd1 100644
--- a/packages/core/js/keywords/domain/word/adjective-keyword-definition.js
+++ b/packages/core/js/keywords/domain/word/adjective-keyword-definition.js
@@ -43,7 +43,8 @@ const WORD_ADJECTIVE_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: WORD_SELECTION_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: WORD_SELECTION_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/js/keywords/domain/word/adverb-keyword-definition.js b/packages/core/js/keywords/domain/word/adverb-keyword-definition.js
index e3937ed1..4ee34b4f 100644
--- a/packages/core/js/keywords/domain/word/adverb-keyword-definition.js
+++ b/packages/core/js/keywords/domain/word/adverb-keyword-definition.js
@@ -43,7 +43,8 @@ const WORD_ADVERB_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: WORD_SELECTION_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: WORD_SELECTION_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/js/keywords/domain/word/conjunction-keyword-definition.js b/packages/core/js/keywords/domain/word/conjunction-keyword-definition.js
index bb20a12c..69e5abdb 100644
--- a/packages/core/js/keywords/domain/word/conjunction-keyword-definition.js
+++ b/packages/core/js/keywords/domain/word/conjunction-keyword-definition.js
@@ -43,7 +43,8 @@ const WORD_CONJUNCTION_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: WORD_SELECTION_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: WORD_SELECTION_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/js/keywords/domain/word/interjection-keyword-definition.js b/packages/core/js/keywords/domain/word/interjection-keyword-definition.js
index 9d5a0d9a..160f3265 100644
--- a/packages/core/js/keywords/domain/word/interjection-keyword-definition.js
+++ b/packages/core/js/keywords/domain/word/interjection-keyword-definition.js
@@ -43,7 +43,8 @@ const WORD_INTERJECTION_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: WORD_SELECTION_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: WORD_SELECTION_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/js/keywords/domain/word/noun-keyword-definition.js b/packages/core/js/keywords/domain/word/noun-keyword-definition.js
index 9f4ca312..3bf4bd1f 100644
--- a/packages/core/js/keywords/domain/word/noun-keyword-definition.js
+++ b/packages/core/js/keywords/domain/word/noun-keyword-definition.js
@@ -43,7 +43,8 @@ const WORD_NOUN_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: WORD_SELECTION_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: WORD_SELECTION_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/js/keywords/domain/word/preposition-keyword-definition.js b/packages/core/js/keywords/domain/word/preposition-keyword-definition.js
index 6b745cbb..9faa9e86 100644
--- a/packages/core/js/keywords/domain/word/preposition-keyword-definition.js
+++ b/packages/core/js/keywords/domain/word/preposition-keyword-definition.js
@@ -43,7 +43,8 @@ const WORD_PREPOSITION_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: WORD_SELECTION_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: WORD_SELECTION_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/js/keywords/domain/word/sample-keyword-definition.js b/packages/core/js/keywords/domain/word/sample-keyword-definition.js
index b4b5a07b..78a63c49 100644
--- a/packages/core/js/keywords/domain/word/sample-keyword-definition.js
+++ b/packages/core/js/keywords/domain/word/sample-keyword-definition.js
@@ -44,7 +44,8 @@ const WORD_SAMPLE_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: WORD_SELECTION_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: WORD_SELECTION_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/js/keywords/domain/word/verb-keyword-definition.js b/packages/core/js/keywords/domain/word/verb-keyword-definition.js
index 1c27517f..932e1a1a 100644
--- a/packages/core/js/keywords/domain/word/verb-keyword-definition.js
+++ b/packages/core/js/keywords/domain/word/verb-keyword-definition.js
@@ -43,7 +43,8 @@ const WORD_VERB_KEYWORD_DEFINITION = {
},
{
name: 'strategy',
- type: WORD_SELECTION_STRATEGY_TYPE,
+ type: 'enum',
+ enumValues: WORD_SELECTION_STRATEGY_TYPE,
required: false,
description:
'The strategy to apply when no words with a matching length are found. Available error handling strategies: fail: Throws an error if no words with the given length are found. shortest: Returns any of the shortest words. closest: Returns any of the words closest to the given length. longest: Returns any of the longest words. any-length: Returns a word with any length.',
diff --git a/packages/core/src/tests/core-api/generateFromTextSpec.test.js b/packages/core/src/tests/core-api/generateFromTextSpec.test.js
index 876195a2..335e3429 100644
--- a/packages/core/src/tests/core-api/generateFromTextSpec.test.js
+++ b/packages/core/src/tests/core-api/generateFromTextSpec.test.js
@@ -98,6 +98,21 @@ test('generateFromTextSpec supports pict-style inline schema definitions', () =>
assertNoCommonErrorPatternsInRows(result.rows);
});
+test('generateFromTextSpec accepts explicit empty string enum values', () => {
+ const result = generateFromTextSpec({
+ textSpec: 'Status\nenum("","A")',
+ rowCount: 20,
+ outputFormat: 'json',
+ });
+
+ expect(result.ok).toBe(true);
+ expect(result.headers).toEqual(['Status']);
+ expect(result.rows).toHaveLength(20);
+ result.rows.forEach((row) => {
+ expect(['', 'A']).toContain(row[0]);
+ });
+});
+
test('generateFromTextSpec serializes object return values as JSON strings', () => {
const result = generateFromTextSpec({
textSpec: 'Currency\nfinance.currency',
@@ -413,9 +428,9 @@ test.each([
message: 'unknown named argument "valuez"',
},
{
- label: 'empty enum values',
- textSpec: 'Status\ndatatype.enum(values="")',
- message: 'argument "values" is required',
+ label: 'accidental empty enum CSV value',
+ textSpec: 'Status\ndatatype.enum(csv="active,,pending")',
+ message: 'enum values cannot be empty',
},
{
label: 'missing enum values',
diff --git a/packages/core/src/tests/data_generation/enum-compiler-integration.test.js b/packages/core/src/tests/data_generation/enum-compiler-integration.test.js
index cbc650fa..11d045cf 100644
--- a/packages/core/src/tests/data_generation/enum-compiler-integration.test.js
+++ b/packages/core/src/tests/data_generation/enum-compiler-integration.test.js
@@ -67,6 +67,17 @@ describe('TestDataRulesCompiler with Enum Support', () => {
expect(compiler.isValid()).toBe(true);
});
+ test('compiles explicit enum with empty string value correctly', () => {
+ const rules = [new TestDataRule('Status', 'enum("", "A")')];
+
+ compiler.compile(rules);
+ compiler.validate();
+
+ expect(rules[0].type).toBe('domain');
+ expect(rules[0].ruleSpec).toBe('datatype.enum("", "A")');
+ expect(compiler.isValid()).toBe(true);
+ });
+
test('compiles full awd enum format correctly', () => {
const rules = [new TestDataRule('Priority', 'awd.datatype.enum("High", "Medium", "Low")')];
diff --git a/packages/core/src/tests/data_generation/enum-surface-parity.test.js b/packages/core/src/tests/data_generation/enum-surface-parity.test.js
index 39add26b..ea7d5df4 100644
--- a/packages/core/src/tests/data_generation/enum-surface-parity.test.js
+++ b/packages/core/src/tests/data_generation/enum-surface-parity.test.js
@@ -131,6 +131,34 @@ describe('enum surface parity', () => {
}
});
+ test('compiled enum surfaces preserve explicit empty string values', () => {
+ const compiler = new TestDataRulesCompiler(faker, RandExp);
+ const rules = [new TestDataRule('Status', 'enum("", "active")')];
+ const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0);
+
+ try {
+ compiler.compile(rules);
+ compiler.validate();
+
+ expect(rules[0]).toMatchObject({
+ type: 'domain',
+ ruleSpec: 'datatype.enum("", "active")',
+ });
+ expect(compiler.isValid()).toBe(true);
+
+ const result = generateFromTextSpec({
+ textSpec: `Status\n${rules[0].ruleSpec}`,
+ rowCount: 1,
+ outputFormat: 'json',
+ });
+
+ expect(result.ok).toBe(true);
+ expect(result.rows).toEqual([['']]);
+ } finally {
+ randomSpy.mockRestore();
+ }
+ });
+
test.each([
['malformed named values quote', 'datatype.enum(values="active,pending)', 'unbalanced expression'],
['unknown named values argument', 'datatype.enum(valuez="active,pending")', 'unknown named argument "valuez"'],
diff --git a/packages/core/src/tests/data_generation/keywords/domain/airline/seat-keyword-definition.test.js b/packages/core/src/tests/data_generation/keywords/domain/airline/seat-keyword-definition.test.js
index 30922e41..d484485e 100644
--- a/packages/core/src/tests/data_generation/keywords/domain/airline/seat-keyword-definition.test.js
+++ b/packages/core/src/tests/data_generation/keywords/domain/airline/seat-keyword-definition.test.js
@@ -39,7 +39,8 @@ describe('airline.seat parameter validation', () => {
test('rejects unsupported aircraftType value before generation', () => {
expect(validateArgs('aircraftType="unknown"')).toEqual({
ok: false,
- error: 'Invalid keyword arguments: argument "aircraftType" must be narrowbody, regional or widebody, not string',
+ error:
+ 'Invalid keyword arguments: argument "aircraftType" must be narrowbody, regional or widebody, not "unknown"',
});
});
});
diff --git a/packages/core/src/tests/data_generation/keywords/domain/autoincrement/timestamp-exec.test.js b/packages/core/src/tests/data_generation/keywords/domain/autoincrement/timestamp-exec.test.js
index 7b286591..2a5d2dc0 100644
--- a/packages/core/src/tests/data_generation/keywords/domain/autoincrement/timestamp-exec.test.js
+++ b/packages/core/src/tests/data_generation/keywords/domain/autoincrement/timestamp-exec.test.js
@@ -48,7 +48,7 @@ describe('autoIncrement.timestamp domain keyword execution', () => {
rowIndex: 0,
})
).toThrow(
- 'Invalid argument for type: expected milliseconds, seconds, minutes, hours, days, weeks, months, or years.'
+ 'Invalid keyword arguments: argument "type" must be milliseconds, seconds, minutes, hours, days, weeks, months or years, not "fortnights"'
);
});
});
diff --git a/packages/core/src/tests/data_generation/keywords/domain/autoincrement/timestamp-keyword-definition.test.js b/packages/core/src/tests/data_generation/keywords/domain/autoincrement/timestamp-keyword-definition.test.js
index 93bdda6d..aee48716 100644
--- a/packages/core/src/tests/data_generation/keywords/domain/autoincrement/timestamp-keyword-definition.test.js
+++ b/packages/core/src/tests/data_generation/keywords/domain/autoincrement/timestamp-keyword-definition.test.js
@@ -51,7 +51,8 @@ describe('autoIncrement.timestamp parameter validation', () => {
test('rejects invalid type type before generation', () => {
expect(validateArgs('start="2026-06-12T12:39:23Z", step=1, type={"bad":true}')).toEqual({
ok: false,
- error: 'Invalid keyword arguments: argument "type" must be string, not object',
+ error:
+ 'Invalid keyword arguments: argument "type" must be milliseconds, seconds, minutes, hours, days, weeks, months or years, not object',
});
});
diff --git a/packages/core/src/tests/data_generation/keywords/domain/datatype/enum-keyword-definition.test.js b/packages/core/src/tests/data_generation/keywords/domain/datatype/enum-keyword-definition.test.js
index 9be7f1a7..670fadf1 100644
--- a/packages/core/src/tests/data_generation/keywords/domain/datatype/enum-keyword-definition.test.js
+++ b/packages/core/src/tests/data_generation/keywords/domain/datatype/enum-keyword-definition.test.js
@@ -35,10 +35,9 @@ describe('datatype.enum parameter validation', () => {
});
});
- test('rejects empty csv enum values before generation', () => {
+ test('accepts explicit empty string enum values before generation', () => {
expect(validateArgs('values=""')).toEqual({
- ok: false,
- error: 'Invalid keyword arguments: argument "values" is required',
+ ok: true,
});
});
@@ -49,8 +48,14 @@ describe('datatype.enum parameter validation', () => {
});
});
- test('rejects blank enum entries before generation', () => {
+ test('accepts explicit empty string entries in enum arrays before generation', () => {
expect(validateArgs('values=["GET",""]')).toEqual({
+ ok: true,
+ });
+ });
+
+ test('rejects accidental empty CSV entries before generation', () => {
+ expect(validateArgs('csv="GET,,POST"')).toEqual({
ok: false,
error: 'Invalid keyword arguments: enum values cannot be empty',
});
diff --git a/packages/core/src/tests/data_generation/unit/domain/domain-keyword-params-usage.test.js b/packages/core/src/tests/data_generation/unit/domain/domain-keyword-params-usage.test.js
index 58ecd469..02a9b89a 100644
--- a/packages/core/src/tests/data_generation/unit/domain/domain-keyword-params-usage.test.js
+++ b/packages/core/src/tests/data_generation/unit/domain/domain-keyword-params-usage.test.js
@@ -1,7 +1,7 @@
import { DOMAIN_KEYWORDS, executeDomainKeyword } from '../../../../../js/domain/domain-keywords.js';
import { faker } from '@faker-js/faker';
import { assertDomainKeywordResult } from './domain-result-assertions.test-helper.js';
-import { sampleValueForKeywordArg, sampleValueForType } from './domain-keyword-sample-values.test-helper.js';
+import { sampleValueForArgSpec, sampleValueForKeywordArg } from './domain-keyword-sample-values.test-helper.js';
function setDeepMethod(root, target, fn) {
const parts = String(target || '')
@@ -53,7 +53,7 @@ function buildValidArgs(keyword) {
for (let index = 0; index < keyword.help.args.length; index += 1) {
const argSpec = keyword.help.args[index];
if (argSpec.required) {
- args[index] = sampleValueForType(argSpec.type);
+ args[index] = sampleValueForArgSpec(argSpec);
}
}
return args;
@@ -125,7 +125,7 @@ describe('domain keyword parameter usage', () => {
});
const args = applyKeywordExecutionDefaults(keyword, buildValidArgs(keyword));
- const sample = sampleValueForKeywordArg(keyword.keyword, argSpec.name, argSpec.type);
+ const sample = sampleValueForKeywordArg(keyword.keyword, argSpec);
args[argIndex] = sample;
executeDomainKeyword(keyword.keyword, { faker, args });
@@ -147,7 +147,7 @@ describe('domain keyword parameter usage', () => {
test(`${keyword.keyword} executes with parameter "${argSpec.name}" against faker`, () => {
const args = applyKeywordExecutionDefaults(keyword, buildValidArgs(keyword));
- args[argIndex] = sampleValueForKeywordArg(keyword.keyword, argSpec.name, argSpec.type);
+ args[argIndex] = sampleValueForKeywordArg(keyword.keyword, argSpec);
if (shouldSkipRuntimeExecution(argSpec)) {
return;
diff --git a/packages/core/src/tests/data_generation/unit/domain/domain-keyword-sample-values.test-helper.js b/packages/core/src/tests/data_generation/unit/domain/domain-keyword-sample-values.test-helper.js
index 2a21564e..028e308f 100644
--- a/packages/core/src/tests/data_generation/unit/domain/domain-keyword-sample-values.test-helper.js
+++ b/packages/core/src/tests/data_generation/unit/domain/domain-keyword-sample-values.test-helper.js
@@ -50,9 +50,35 @@ function sampleValueForType(typeName, { arraySample = ['x', 'y'] } = {}) {
return 'sample';
}
-function sampleValueForKeywordArg(keywordName, argName, typeName) {
+function sampleValueForArgSpec(argSpec, options) {
+ if (argSpec?.type === 'enum' && Array.isArray(argSpec.enumValues) && argSpec.enumValues.length > 0) {
+ const nonEmptyValue = argSpec.enumValues.find((entry) => String(entry).length > 0);
+ return nonEmptyValue ?? argSpec.enumValues[0];
+ }
+
+ return sampleValueForType(argSpec?.type, options);
+}
+
+function normalizeArgInput(argOrName, typeName) {
+ if (argOrName && typeof argOrName === 'object') {
+ return {
+ argName: argOrName.name,
+ typeName: argOrName.type,
+ argSpec: argOrName,
+ };
+ }
+
+ return {
+ argName: argOrName,
+ typeName,
+ argSpec: undefined,
+ };
+}
+
+function sampleValueForKeywordArg(keywordName, argOrName, typeName) {
+ const { argName, typeName: resolvedTypeName, argSpec } = normalizeArgInput(argOrName, typeName);
const key = `${keywordName}.${argName}`;
- const type = String(typeName || '');
+ const type = String(resolvedTypeName || '');
if (key === 'date.between.from' || key === 'date.betweens.from') return 1577836800000;
if (key === 'date.between.to' || key === 'date.betweens.to') return 1609372800000;
@@ -131,6 +157,7 @@ function sampleValueForKeywordArg(keywordName, argName, typeName) {
if (type.includes('regexp')) return '[A-Z]';
if (type.includes('boolean')) return true;
if (type.includes('array')) return ['x', 'y'];
+ if (argSpec) return sampleValueForArgSpec(argSpec);
return sampleValueForType(type);
}
@@ -150,4 +177,4 @@ function valueToInvocationLiteral(value) {
throw new Error(`Unsupported invocation literal value: ${String(value)}`);
}
-export { sampleValueForKeywordArg, sampleValueForType, valueToInvocationLiteral };
+export { sampleValueForArgSpec, sampleValueForKeywordArg, sampleValueForType, valueToInvocationLiteral };
diff --git a/packages/core/src/tests/data_generation/unit/domain/domain-param-invocation-coverage.test-helper.js b/packages/core/src/tests/data_generation/unit/domain/domain-param-invocation-coverage.test-helper.js
index a2737f7f..c6c563d3 100644
--- a/packages/core/src/tests/data_generation/unit/domain/domain-param-invocation-coverage.test-helper.js
+++ b/packages/core/src/tests/data_generation/unit/domain/domain-param-invocation-coverage.test-helper.js
@@ -28,7 +28,7 @@ function addDomainParamInvocationCoverageTests(domainName) {
for (const keyword of keywords) {
test(`${keyword.keyword} supports equivalent positional and named documented params`, () => {
- const sampleArgs = keyword.help.args.map((arg) => sampleValueForKeywordArg(keyword.keyword, arg.name, arg.type));
+ const sampleArgs = keyword.help.args.map((arg) => sampleValueForKeywordArg(keyword.keyword, arg));
const positionalInvocation = `${keyword.keyword}(${sampleArgs.map(valueToInvocationLiteral).join(', ')})`;
const namedInvocation = `${keyword.keyword}(${keyword.help.args
diff --git a/packages/core/src/tests/data_generation/unit/domain/domainKeywords.test.js b/packages/core/src/tests/data_generation/unit/domain/domainKeywords.test.js
index daa3bcd6..b227ab5c 100644
--- a/packages/core/src/tests/data_generation/unit/domain/domainKeywords.test.js
+++ b/packages/core/src/tests/data_generation/unit/domain/domainKeywords.test.js
@@ -736,7 +736,7 @@ describe('faker keyword invocation styles', () => {
const argsForInvocation = keyword.keyword.startsWith('lorem.')
? keyword.help.args.filter((arg) => ['min', 'max'].includes(arg.name))
: keyword.help.args;
- const sampleArgs = argsForInvocation.map((arg) => sampleValueForKeywordArg(keyword.keyword, arg.name, arg.type));
+ const sampleArgs = argsForInvocation.map((arg) => sampleValueForKeywordArg(keyword.keyword, arg));
if (keyword.keyword === 'datatype.boolean') {
sampleArgs[0] = 0.5;
}
diff --git a/packages/core/src/tests/data_generation/unit/enum/enumParser.test.js b/packages/core/src/tests/data_generation/unit/enum/enumParser.test.js
index f9d1c4f3..f4baaac6 100644
--- a/packages/core/src/tests/data_generation/unit/enum/enumParser.test.js
+++ b/packages/core/src/tests/data_generation/unit/enum/enumParser.test.js
@@ -17,6 +17,13 @@ const VALID_ENUM_RULE_SPECS = [
explicit: false,
source: 'implicit-csv',
},
+ {
+ label: 'schema shorthand quoted empty CSV value',
+ ruleSpec: '"",active',
+ values: ['', 'active'],
+ explicit: false,
+ source: 'implicit-csv',
+ },
{
label: 'schema shorthand CSV literal with doubled quote escaping',
ruleSpec: 'active,"needs ""quote",pending',
@@ -38,6 +45,13 @@ const VALID_ENUM_RULE_SPECS = [
explicit: true,
source: 'function',
},
+ {
+ label: 'multiple positional strings with explicit empty value',
+ ruleSpec: 'enum("", "active")',
+ values: ['', 'active'],
+ explicit: true,
+ source: 'function',
+ },
{
label: 'positional string array',
ruleSpec: 'enum(["active", "needs space", "pending"])',
@@ -73,6 +87,20 @@ const VALID_ENUM_RULE_SPECS = [
explicit: true,
source: 'function',
},
+ {
+ label: 'datatype enum named string array with explicit empty value',
+ ruleSpec: 'datatype.enum(values=["", "active"])',
+ values: ['', 'active'],
+ explicit: true,
+ source: 'function',
+ },
+ {
+ label: 'datatype enum named single empty value',
+ ruleSpec: 'datatype.enum(values="")',
+ values: [''],
+ explicit: true,
+ source: 'function',
+ },
{
label: 'awd datatype enum named string array',
ruleSpec: 'awd.datatype.enum(values=["active", "inactive", "pending"])',
diff --git a/packages/core/src/tests/data_generation/unit/enum/enumTestDataRuleValidator.test.js b/packages/core/src/tests/data_generation/unit/enum/enumTestDataRuleValidator.test.js
index d14bd43f..61861045 100644
--- a/packages/core/src/tests/data_generation/unit/enum/enumTestDataRuleValidator.test.js
+++ b/packages/core/src/tests/data_generation/unit/enum/enumTestDataRuleValidator.test.js
@@ -69,6 +69,17 @@ describe('EnumTestDataRuleValidator', () => {
expect(validator.isValid()).toBe(true);
});
+ test('accepts explicit enum syntax with an empty string value', () => {
+ const rule = new TestDataRule('MaybeBlank', 'enum("", "A")');
+ rule.type = 'enum';
+
+ const validator = new EnumTestDataRuleValidator();
+ const isValid = validator.validate(rule);
+
+ expect(isValid).toBe(true);
+ expect(validator.isValid()).toBe(true);
+ });
+
test('validates command-looking comma-separated values as enum literals', () => {
const rule = new TestDataRule('Names', 'person.firstName,person.lastName');
rule.type = 'enum';
|