From 1d9568f40b5447ff1e47e035c503d9cb41d789e5 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 2 Jul 2026 19:55:39 +0100 Subject: [PATCH 1/6] Add enum picker editing for schema params --- .../shared-schema-definition.stories.js | 52 ++++ .../generator/functional/schema-edit.spec.js | 21 ++ .../params-editor-dialog.component.js | 9 + .../components/schema-editor.component.js | 12 +- .../test-data/help/help-model-builder.js | 3 + .../test-data/ui/params-editor-modal.css | 13 +- .../test-data/ui/params-editor-modal.js | 254 ++++++++++++++---- .../tests/shared/help-model-builder.test.js | 7 + .../shared-schema-editor-controller.test.js | 76 ++++++ .../utils/faker-command-help-metadata.test.js | 6 +- .../tests/utils/params-editor-modal.test.js | 189 +++++++++++++ packages/core/js/domain/domain-keywords.js | 37 ++- .../faker/faker-helper-keyword-definitions.js | 6 + .../domain/airline/seat-keyword-definition.js | 5 +- .../timestamp-keyword-definition.js | 14 +- .../domain/color/cmyk-keyword-definition.js | 5 +- ...or-by-csscolor-space-keyword-definition.js | 10 +- .../domain/color/hsl-keyword-definition.js | 5 +- .../domain/color/hwb-keyword-definition.js | 5 +- .../domain/color/lab-keyword-definition.js | 5 +- .../domain/color/lch-keyword-definition.js | 5 +- .../domain/color/rgb-keyword-definition.js | 10 +- .../commerce/isbn-keyword-definition.js | 3 +- .../date/birthdate-keyword-definition.js | 3 +- .../bitcoin-address-keyword-definition.js | 10 +- .../internet/ipv4-keyword-definition.js | 17 +- .../domain/internet/mac-keyword-definition.js | 5 +- .../domain/internet/url-keyword-definition.js | 5 +- .../country-code-keyword-definition.js | 3 +- .../domain/lorem/word-keyword-definition.js | 5 +- .../person/first-name-keyword-definition.js | 5 +- .../person/last-name-keyword-definition.js | 5 +- .../person/middle-name-keyword-definition.js | 5 +- .../person/prefix-keyword-definition.js | 5 +- .../domain/phone/number-keyword-definition.js | 5 +- .../domain/string/alpha-keyword-definition.js | 5 +- .../string/alphanumeric-keyword-definition.js | 5 +- .../string/hexadecimal-keyword-definition.js | 5 +- .../domain/string/uuid-keyword-definition.js | 3 +- .../word/adjective-keyword-definition.js | 5 +- .../domain/word/adverb-keyword-definition.js | 5 +- .../word/conjunction-keyword-definition.js | 5 +- .../word/interjection-keyword-definition.js | 5 +- .../domain/word/noun-keyword-definition.js | 5 +- .../word/preposition-keyword-definition.js | 5 +- .../domain/word/sample-keyword-definition.js | 5 +- .../domain/word/verb-keyword-definition.js | 5 +- .../autoincrement/timestamp-exec.test.js | 2 +- .../domain-keyword-params-usage.test.js | 22 +- .../unit/domain/domainKeywords.test.js | 11 +- 50 files changed, 765 insertions(+), 153 deletions(-) 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..6b94c9cc 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 @@ -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..c878666f 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,20 @@ 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(); + await this.enumSelect(name).selectOption(String(value)); + } + 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..294fb57f 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) { @@ -196,6 +196,16 @@ class SchemaEditorComponent { await this.paramsEditor.apply(); } + async editRowEnumParamsWithDialog(index, valuesByName) { + 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.selectEnumValue(name, value); + } + await this.paramsEditor.apply(); + } + async dragRowToIndex(fromIndex, toIndex, { placement = 'before' } = {}) { await this.ensureSchemaMode(); const source = this.row(fromIndex).locator('[data-action="drag"]'); 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/ui/params-editor-modal.css b/packages/core-ui/js/gui_components/shared/test-data/ui/params-editor-modal.css index b663c276..85763bd8 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,7 +275,8 @@ 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; } 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..3ab1bcf7 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,34 @@ function isRawPreferredType(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 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' + ? isRawPreferredType(paramType) + ? 'raw' + : 'text' + : mode; + if (mode === 'enum' && !isNumericEnumToken(rawValue)) { + return JSON.stringify(unquoteValue(rawValue)); + } if (resolvedMode === 'raw') { return rawValue.trim(); } @@ -337,8 +409,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 +431,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 +510,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 +570,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 +653,10 @@ function renderValueEditor(entry, index) { `; } + if (isEnumParam(entry)) { + return renderEnumValueEditor(entry, index); + } + return ` 0, + }; } function renderEntryRows(entries = []) { @@ -766,6 +895,7 @@ 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 helpTooltipService = createHelpTooltipService({ documentObj, windowObj, @@ -799,10 +929,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 +1011,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 = valueInputs()[0] || enumInputs()[0] || booleanInputs()[0]; const focusFn = windowObj?.requestAnimationFrame?.bind(windowObj) || windowObj?.setTimeout?.bind(windowObj); focusFn?.(() => (firstInput || getFocusableElements(dialogElement)[0] || dialogElement)?.focus?.()); }); @@ -892,6 +1028,8 @@ export { splitTopLevelCommaSeparated, parseInitialParamEntries, inferEditorMode, + inferEnumChoicesFromType, + resolveEnumChoices, buildParamsTextFromEditorEntries, openParamsEditorModal, }; 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-editor-controller.test.js b/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js index 76b18e46..770a7a70 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,82 @@ 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(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..9a241858 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 @@ -4,6 +4,8 @@ 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 +74,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 +183,20 @@ describe('params editor modal', () => { }); }); + 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: [ @@ -280,6 +337,138 @@ 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 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('keeps apply enabled when semantic validation returns a warning', async () => { const promise = openParamsEditorModal({ documentObj: document, diff --git a/packages/core/js/domain/domain-keywords.js b/packages/core/js/domain/domain-keywords.js index 04f9cd3f..8df91bdc 100644 --- a/packages/core/js/domain/domain-keywords.js +++ b/packages/core/js/domain/domain-keywords.js @@ -123,6 +123,9 @@ function buildDomainKeywordCatalog(definitions = DOMAIN_KEYWORD_DEFINITIONS) { variadic: arg?.variadic === true, 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') @@ -270,12 +273,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; @@ -320,10 +337,8 @@ function normalizeLiteralTypeToken(typeToken) { return typeToken; } -function formatExpectedType(typeName) { - const allowed = String(typeName || '') - .split('|') - .map((entry) => entry.trim()) +function formatExpectedType(specOrType) { + const allowed = getExpectedTypeTokens(specOrType) .filter(Boolean) .map((entry) => normalizeLiteralTypeToken(entry)); @@ -459,7 +474,7 @@ function createRequiredArgError(spec) { 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 ${describeValueType(value)}`, }; } @@ -475,7 +490,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); } @@ -503,7 +518,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 aed8925b..edb2cbf2 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 : [], })) : []; const returnType = commandHelp.returnType || ''; 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/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 e243cb92..9e7057b1 100644 --- a/packages/core/js/keywords/domain/lorem/word-keyword-definition.js +++ b/packages/core/js/keywords/domain/lorem/word-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-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 LOREM_WORD_KEYWORD_DEFINITION = { keyword: 'lorem.word', @@ -63,7 +63,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/string/alpha-keyword-definition.js b/packages/core/js/keywords/domain/string/alpha-keyword-definition.js index 12fffec6..9a4a3997 100644 --- a/packages/core/js/keywords/domain/string/alpha-keyword-definition.js +++ b/packages/core/js/keywords/domain/string/alpha-keyword-definition.js @@ -1,6 +1,6 @@ import { validateAlphaStringValue } from '../../../command-help/command-help-validators.js'; -const STRING_CASING_TYPE = 'upper|lower|mixed'; +const STRING_CASING_TYPE = ['upper', 'lower', 'mixed']; const STRING_ALPHA_KEYWORD_DEFINITION = { keyword: 'string.alpha', @@ -56,7 +56,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 0315cbf6..f760a02e 100644 --- a/packages/core/js/keywords/domain/string/alphanumeric-keyword-definition.js +++ b/packages/core/js/keywords/domain/string/alphanumeric-keyword-definition.js @@ -1,6 +1,6 @@ import { validateAlphanumericStringValue } from '../../../command-help/command-help-validators.js'; -const STRING_CASING_TYPE = 'upper|lower|mixed'; +const STRING_CASING_TYPE = ['upper', 'lower', 'mixed']; const STRING_ALPHANUMERIC_KEYWORD_DEFINITION = { keyword: 'string.alphanumeric', @@ -46,7 +46,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 99b2b516..724d13aa 100644 --- a/packages/core/js/keywords/domain/string/hexadecimal-keyword-definition.js +++ b/packages/core/js/keywords/domain/string/hexadecimal-keyword-definition.js @@ -1,6 +1,6 @@ import { validateHexadecimalStringValue } from '../../../command-help/command-help-validators.js'; -const STRING_CASING_TYPE = 'upper|lower|mixed'; +const STRING_CASING_TYPE = ['upper', 'lower', 'mixed']; const STRING_HEXADECIMAL_KEYWORD_DEFINITION = { keyword: 'string.hexadecimal', @@ -40,7 +40,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 4aa505ee..93c2dc30 100644 --- a/packages/core/js/keywords/domain/word/adjective-keyword-definition.js +++ b/packages/core/js/keywords/domain/word/adjective-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-validators.js'; -const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length'; +const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length']; const WORD_ADJECTIVE_KEYWORD_DEFINITION = { keyword: 'word.adjective', @@ -52,7 +52,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 350f0784..e2333be5 100644 --- a/packages/core/js/keywords/domain/word/adverb-keyword-definition.js +++ b/packages/core/js/keywords/domain/word/adverb-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-validators.js'; -const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length'; +const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length']; const WORD_ADVERB_KEYWORD_DEFINITION = { keyword: 'word.adverb', @@ -52,7 +52,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 abe5a25b..41123151 100644 --- a/packages/core/js/keywords/domain/word/conjunction-keyword-definition.js +++ b/packages/core/js/keywords/domain/word/conjunction-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-validators.js'; -const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length'; +const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length']; const WORD_CONJUNCTION_KEYWORD_DEFINITION = { keyword: 'word.conjunction', @@ -52,7 +52,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 c8d38511..729fb0c0 100644 --- a/packages/core/js/keywords/domain/word/interjection-keyword-definition.js +++ b/packages/core/js/keywords/domain/word/interjection-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-validators.js'; -const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length'; +const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length']; const WORD_INTERJECTION_KEYWORD_DEFINITION = { keyword: 'word.interjection', @@ -52,7 +52,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 2d2db083..cf975168 100644 --- a/packages/core/js/keywords/domain/word/noun-keyword-definition.js +++ b/packages/core/js/keywords/domain/word/noun-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-validators.js'; -const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length'; +const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length']; const WORD_NOUN_KEYWORD_DEFINITION = { keyword: 'word.noun', @@ -52,7 +52,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 2f17bc1d..66f45d3b 100644 --- a/packages/core/js/keywords/domain/word/preposition-keyword-definition.js +++ b/packages/core/js/keywords/domain/word/preposition-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-validators.js'; -const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length'; +const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length']; const WORD_PREPOSITION_KEYWORD_DEFINITION = { keyword: 'word.preposition', @@ -52,7 +52,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 a2d2ea2f..49a65579 100644 --- a/packages/core/js/keywords/domain/word/sample-keyword-definition.js +++ b/packages/core/js/keywords/domain/word/sample-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-validators.js'; -const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length'; +const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length']; const WORD_SAMPLE_KEYWORD_DEFINITION = { keyword: 'word.sample', @@ -53,7 +53,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 cdaa6bba..d720a827 100644 --- a/packages/core/js/keywords/domain/word/verb-keyword-definition.js +++ b/packages/core/js/keywords/domain/word/verb-keyword-definition.js @@ -1,6 +1,6 @@ import { validateStringValue } from '../../../command-help/command-help-validators.js'; -const WORD_SELECTION_STRATEGY_TYPE = 'fail|closest|shortest|longest|any-length'; +const WORD_SELECTION_STRATEGY_TYPE = ['fail', 'closest', 'shortest', 'longest', 'any-length']; const WORD_VERB_KEYWORD_DEFINITION = { keyword: 'word.verb', @@ -52,7 +52,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/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..da3f8fda 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 string' ); }); }); 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 b60cc86a..19f19f15 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 @@ -90,9 +90,19 @@ function sampleValueForType(type) { return 'sample'; } -function sampleValueForKeywordArg(keywordName, argName, typeName) { +function sampleValueForArgSpec(argSpec) { + 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); +} + +function sampleValueForKeywordArg(keywordName, argSpec) { + const argName = argSpec?.name; const key = `${keywordName}.${argName}`; - const type = String(typeName || ''); + const type = String(argSpec?.type || ''); if (key === 'date.between.from' || key === 'date.betweens.from') return new Date('2020-01-01T00:00:00.000Z').getTime(); @@ -156,7 +166,7 @@ function sampleValueForKeywordArg(keywordName, argName, typeName) { if (type.includes('regexp')) return '[A-Z]'; if (type.includes('boolean')) return true; if (type.includes('array')) return ['a', 'b']; - return sampleValueForType(type); + return sampleValueForArgSpec(argSpec); } function buildValidArgs(keyword) { @@ -164,7 +174,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; @@ -225,7 +235,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 }); @@ -245,7 +255,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(keyword.keyword, argSpec.name)) { return; 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 e8ad3ac1..72c918ad 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 @@ -554,6 +554,15 @@ function setDeepMethod(root, target, fn) { node[parts[parts.length - 1]] = fn; } +function sampleValueForArg(arg) { + if (arg?.type === 'enum' && Array.isArray(arg.enumValues) && arg.enumValues.length > 0) { + const nonEmptyValue = arg.enumValues.find((entry) => String(entry).length > 0); + return nonEmptyValue ?? arg.enumValues[0]; + } + + return sampleValueForType(arg?.type); +} + function sampleValueForType(type) { const allowed = String(type || '') .split('|') @@ -677,7 +686,7 @@ describe('faker keyword invocation styles', () => { for (const keyword of fakerKeywordsWithArgs) { test(`${keyword.keyword} supports equivalent positional and named argument invocation`, () => { - const sampleArgs = keyword.help.args.map((arg) => sampleValueForType(arg.type)); + const sampleArgs = keyword.help.args.map((arg) => sampleValueForArg(arg)); if (keyword.keyword === 'datatype.boolean') { sampleArgs[0] = 0.5; } From 22ce5d6cbe4e9a337640f4dcff8f9f2a6c5935f1 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Thu, 2 Jul 2026 23:01:11 +0100 Subject: [PATCH 2/6] Address enum picker review feedback --- .../params-editor-dialog.component.js | 3 +- .../components/schema-editor.component.js | 16 +++-- .../test-data/ui/params-editor-modal.js | 13 +++- .../tests/utils/params-editor-modal.test.js | 66 +++++++++++++++++++ packages/core/js/domain/domain-keywords.js | 13 +++- .../airline/seat-keyword-definition.test.js | 3 +- .../autoincrement/timestamp-exec.test.js | 2 +- 7 files changed, 102 insertions(+), 14 deletions(-) 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 c878666f..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 @@ -31,7 +31,8 @@ class ParamsEditorDialogComponent { async selectEnumValue(name, value) { await this.expectOpen(); - await this.enumSelect(name).selectOption(String(value)); + const option = String(value).length === 0 ? { label: '""' } : String(value); + await this.enumSelect(name).selectOption(option); } async apply() { 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 294fb57f..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 @@ -187,21 +187,23 @@ class SchemaEditorComponent { } async editRowParamsWithDialog(index, valuesByName) { - 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.editRowParamsWithDialogFlow(index, valuesByName, async (name, value) => { await this.paramsEditor.setValue(name, value); - } - await this.paramsEditor.apply(); + }); } 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.selectEnumValue(name, value); + await setParamValue(name, value); } await this.paramsEditor.apply(); } 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 3ab1bcf7..425fe34c 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 @@ -675,9 +675,10 @@ function readRenderedEntryState(rootElement, entry, index) { const checkedBooleanOption = rootElement.querySelector( `[data-role="params-editor-boolean"][data-index="${index}"]:checked` ); + const value = checkedBooleanOption?.value ?? ''; return { - value: checkedBooleanOption?.value ?? '', - isSet: Boolean(checkedBooleanOption), + value, + isSet: String(value).trim().length > 0, }; } @@ -896,6 +897,12 @@ function openParamsEditorModal({ 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, @@ -1018,7 +1025,7 @@ function openParamsEditorModal({ documentObj.body.appendChild(overlay); helpTooltipService.update(); syncPreview(); - const firstInput = valueInputs()[0] || enumInputs()[0] || booleanInputs()[0]; + const firstInput = editorInputs()[0]; const focusFn = windowObj?.requestAnimationFrame?.bind(windowObj) || windowObj?.setTimeout?.bind(windowObj); focusFn?.(() => (firstInput || getFocusableElements(dialogElement)[0] || dialogElement)?.focus?.()); }); 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 9a241858..df89568b 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 @@ -469,6 +469,32 @@ describe('params editor modal', () => { await expect(promise).resolves.toBeNull(); }); + 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, @@ -780,6 +806,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/domain/domain-keywords.js b/packages/core/js/domain/domain-keywords.js index 8b4235ba..c1b7631b 100644 --- a/packages/core/js/domain/domain-keywords.js +++ b/packages/core/js/domain/domain-keywords.js @@ -550,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)}, not ${describeValueType(value)}`, + error: `Invalid keyword arguments: argument "${spec.name}" must be ${formatExpectedType(spec)}, not ${describeMismatchedArgValue(spec, value)}`, }; } 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 da3f8fda..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 keyword arguments: argument "type" must be milliseconds, seconds, minutes, hours, days, weeks, months or years, not string' + 'Invalid keyword arguments: argument "type" must be milliseconds, seconds, minutes, hours, days, weeks, months or years, not "fortnights"' ); }); }); From 1c64059180243a85fef4f7e68b99c146477fea71 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Fri, 3 Jul 2026 08:45:00 +0100 Subject: [PATCH 3/6] Auto-quote string-capable params Refs #307 --- .../test-data/ui/params-editor-modal.js | 53 ++++++- .../tests/utils/params-editor-modal.test.js | 147 ++++++++++++++++++ 2 files changed, 197 insertions(+), 3 deletions(-) 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 425fe34c..b5ab808e 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 @@ -373,6 +373,31 @@ 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()); } @@ -381,6 +406,24 @@ 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) { @@ -394,9 +437,13 @@ function formatEditorValue(value, mode, paramType = '') { mode === 'enum' && isNumericEnumToken(rawValue) ? 'raw' : mode === 'auto' - ? isRawPreferredType(paramType) - ? 'raw' - : 'text' + ? isStringCapableUnionType(paramType) + ? shouldKeepStringUnionValueRaw(rawValue, paramType) + ? 'raw' + : 'text' + : isRawPreferredType(paramType) + ? 'raw' + : 'text' : mode; if (mode === 'enum' && !isNumericEnumToken(rawValue)) { return JSON.stringify(unquoteValue(rawValue)); 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 df89568b..690bc789 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 @@ -183,6 +183,66 @@ 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({ @@ -273,6 +333,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: [ @@ -469,6 +540,82 @@ describe('params editor modal', () => { 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, From 4601c148e7e175ed7df118b925c64a71af5d3bae Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Fri, 3 Jul 2026 13:01:44 +0100 Subject: [PATCH 4/6] Fix datatype enum empty string values Refs #308 --- .../generator/functional/schema-edit.spec.js | 4 +- .../shared/schema-row-rule-mapper.js | 12 ++++- .../generator/schema-row-rule-mapper.test.js | 9 ++++ .../shared-schema-definition-view.test.js | 4 +- .../enum/enumTestDataRuleValidator.js | 6 --- .../js/data_generation/utils/enumParser.js | 50 +++++++++++++------ .../keywords/domain/datatype/datatype-enum.js | 20 ++++---- .../datatype/enum-keyword-definition.js | 17 ++++--- .../core-api/generateFromTextSpec.test.js | 21 ++++++-- .../enum-compiler-integration.test.js | 11 ++++ .../enum-surface-parity.test.js | 28 +++++++++++ .../datatype/enum-keyword-definition.test.js | 13 +++-- .../unit/enum/enumParser.test.js | 28 +++++++++++ .../enum/enumTestDataRuleValidator.test.js | 11 ++++ 14 files changed, 184 insertions(+), 50 deletions(-) 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 6b94c9cc..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(''); 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/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/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/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/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/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/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/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'; From a78341a26c59332c230d3b1e327200603cad1b57 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Fri, 3 Jul 2026 13:39:23 +0100 Subject: [PATCH 5/6] Restore focus after params apply Refs #310 --- .../test-data/schema/shared-schema-editor-controller.js | 9 +++++++++ .../tests/shared/shared-schema-editor-controller.test.js | 1 + 2 files changed, 10 insertions(+) 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/src/tests/shared/shared-schema-editor-controller.test.js b/packages/core-ui/src/tests/shared/shared-schema-editor-controller.test.js index 770a7a70..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 @@ -270,6 +270,7 @@ describe('createSharedSchemaEditorController', () => { 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: [ From 37afbcb22a2d7ba672bb655bc20c15dc838a50de Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Fri, 3 Jul 2026 13:42:58 +0100 Subject: [PATCH 6/6] Stack params editor rows on mobile Refs #311 --- .../test-data/ui/params-editor-modal.css | 75 ++++++++++++++++++- .../test-data/ui/params-editor-modal.js | 8 +- .../tests/utils/params-editor-modal.test.js | 37 +++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) 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 85763bd8..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 @@ -282,11 +282,84 @@ body.theme-dark { } @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 b5ab808e..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 @@ -763,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 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 690bc789..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,4 +1,5 @@ import { JSDOM } from 'jsdom'; +import { readFileSync } from 'node:fs'; import { fireEvent, within } from '@testing-library/dom'; import { jest } from '@jest/globals'; import { @@ -444,6 +445,31 @@ describe('params editor modal', () => { 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, @@ -747,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,