diff --git a/apps/web/src/stories/population-actions.stories.js b/apps/web/src/stories/population-actions.stories.js index 0b86966f..a6b1d7d4 100644 --- a/apps/web/src/stories/population-actions.stories.js +++ b/apps/web/src/stories/population-actions.stories.js @@ -26,8 +26,10 @@ function renderPopulationActionsStory(args) { generatePairwiseBusy: args.generatePairwiseBusy, generateLabel: args.generateLabel, generatePairwiseLabel: args.generatePairwiseLabel, + generateSchemaLabel: 'Grid to Enum Schema', generateHelpHtml: args.generateHelpHtml, generatePairwiseHelpHtml: args.generatePairwiseHelpHtml, + generateSchemaHelpHtml: '
Scan the current grid and build an enum-only schema from visible values.
', }, callbacks: { onGenerate: () => { @@ -38,6 +40,10 @@ function renderPopulationActionsStory(args) { args.onGeneratePairwise?.(); result.textContent = 'action:generate-secondary'; }, + onGenerateSchemaFromGrid: () => { + args.onGenerateSchemaFromGrid?.(); + result.textContent = 'action:generate-schema'; + }, }, }); @@ -79,6 +85,7 @@ const meta = { 'Generate n-wise combinations from enum columns in the current schema directly into the grid.
', onGenerate: fn(), onGeneratePairwise: fn(), + onGenerateSchemaFromGrid: fn(), }, argTypes: { pairwiseVisible: { @@ -117,6 +124,10 @@ const meta = { description: 'Storybook action fired when the secondary action button is clicked.', table: { category: 'Events' }, }, + onGenerateSchemaFromGrid: { + description: 'Storybook action fired when the Grid to Enum Schema button is clicked.', + table: { category: 'Events' }, + }, }, render: renderPopulationActionsStory, }; @@ -137,7 +148,8 @@ export const Default = { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate', exact: true })).toBeEnabled(); await expect(canvas.queryByRole('button', { name: 'Generate Combinations' })).toBeNull(); - await expect(canvas.getAllByRole('button', { name: /show .* help/i })).toHaveLength(1); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeVisible(); + await expect(canvas.getAllByRole('button', { name: /show .* help/i })).toHaveLength(2); await userEvent.click(canvas.getByRole('button', { name: 'Generate', exact: true })); await expect(canvas.getByText('action:generate')).toBeVisible(); }, @@ -160,7 +172,8 @@ export const PairwiseAvailable = { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate Combinations' })).toBeVisible(); await expect(canvas.getByRole('button', { name: 'Generate Combinations' })).toBeEnabled(); - await expect(canvas.getAllByRole('button', { name: /show .* help/i })).toHaveLength(2); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeVisible(); + await expect(canvas.getAllByRole('button', { name: /show .* help/i })).toHaveLength(3); await userEvent.click(canvas.getByRole('button', { name: 'Generate Combinations' })); await expect(canvas.getByText('action:generate-secondary')).toBeVisible(); }, @@ -185,6 +198,7 @@ export const BusyStates = { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate', exact: true })).toBeDisabled(); await expect(canvas.getByRole('button', { name: 'Generate Combinations' })).toBeDisabled(); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeEnabled(); await expect(canvas.getByRole('button', { name: 'Generate', exact: true })).toHaveAttribute( 'aria-disabled', 'true' diff --git a/apps/web/src/stories/test-data-population-toolbar.stories.js b/apps/web/src/stories/test-data-population-toolbar.stories.js index aaec013e..6a2ec3af 100644 --- a/apps/web/src/stories/test-data-population-toolbar.stories.js +++ b/apps/web/src/stories/test-data-population-toolbar.stories.js @@ -27,6 +27,7 @@ function renderToolbarStory(args) { pairwiseVisible: args.pairwiseVisible, generateBusy: args.generateBusy, generatePairwiseBusy: args.generatePairwiseBusy, + generateSchemaBusy: args.generateSchemaBusy, modeOptions: [ { value: 'new-table', label: 'New Table' }, { value: 'amend-table', label: 'Amend Table' }, @@ -49,6 +50,10 @@ function renderToolbarStory(args) { args.onGenerateCombinations?.(); log.textContent = 'action:generate-combinations'; }, + onGenerateSchemaFromGrid: () => { + args.onGenerateSchema?.(); + log.textContent = 'action:generate-schema'; + }, onModeChange: (mode) => { args.onModeChange?.(mode); log.textContent = `selected:${mode}`; @@ -87,10 +92,12 @@ const meta = { pairwiseVisible: false, generateBusy: false, generatePairwiseBusy: false, + generateSchemaBusy: false, rowCount: 1, maxWidth: '', onGenerate: fn(), onGenerateCombinations: fn(), + onGenerateSchema: fn(), onModeChange: fn(), }, argTypes: { @@ -111,6 +118,10 @@ const meta = { control: 'boolean', description: 'Disables the combinations action when true.', }, + generateSchemaBusy: { + control: 'boolean', + description: 'Disables the Grid to Enum Schema action when true.', + }, rowCount: { control: 'number', description: 'Initial count shown by the shared row-count control.', @@ -137,6 +148,7 @@ export const Default = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate' })).toBeVisible(); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeVisible(); await expect(canvas.getByRole('spinbutton', { name: 'How Many?' })).toHaveValue(1); await expect(canvas.getByRole('radio', { name: 'New Table' })).toBeChecked(); await userEvent.click(canvas.getByRole('button', { name: 'Generate' })); @@ -149,6 +161,7 @@ export const PairwiseAndBusy = { pairwiseVisible: true, generateBusy: true, generatePairwiseBusy: true, + generateSchemaBusy: true, selectedMode: 'amend-selected', rowCount: 3, }, @@ -164,6 +177,7 @@ export const PairwiseAndBusy = { const canvas = within(canvasElement); await expect(canvas.getByRole('button', { name: 'Generate' })).toBeDisabled(); await expect(canvas.getByRole('button', { name: 'Generate Combinations' })).toBeDisabled(); + await expect(canvas.getByRole('button', { name: 'Grid to Enum Schema' })).toBeDisabled(); await expect(canvas.getByRole('radio', { name: 'Amend Selected' })).toBeChecked(); }, }; diff --git a/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js b/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js index 50ba916b..8b23fdda 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/confirm-dialog.component.js @@ -18,11 +18,11 @@ class ConfirmDialogComponent { await expect(this.backdrop).toBeHidden(); } - async confirmIfVisible() { + async confirmIfVisible(options = {}) { if (!(await this.isVisible())) { return false; } - await this.confirm(); + await this.confirm(options); return true; } @@ -34,9 +34,9 @@ class ConfirmDialogComponent { return true; } - async confirm() { + async confirm({ confirmLabel = /^ok$/i } = {}) { await this.expectVisible(); - await this.backdrop.getByRole('button', { name: /^ok$/i }).click(); + await this.backdrop.getByRole('button', { name: confirmLabel }).click(); await this.expectHidden(); } diff --git a/apps/web/src/tests/browser/app/abstractions/components/test-data-panel.component.js b/apps/web/src/tests/browser/app/abstractions/components/test-data-panel.component.js index 4959f2dd..c9c82786 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/test-data-panel.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/test-data-panel.component.js @@ -1,5 +1,7 @@ const { expect } = require('@playwright/test'); const { GridRendererComponent } = require('./grid-renderer.component'); +const { ConfirmDialogComponent } = require('./confirm-dialog.component'); +const { TextInputDialogComponent } = require('./text-input-dialog.component'); const { SchemaEditorComponent } = require('../../../shared/abstractions/components/schema-editor.component'); const { OverlaySafeActivationComponent, @@ -14,6 +16,7 @@ class TestDataPanelComponent { this.heading = this.container.getByText('Test Data', { exact: true }); this.generateButton = this.container.getByRole('button', { name: 'Generate', exact: true }); this.generatePairwiseButton = this.container.getByRole('button', { name: 'Generate Combinations' }); + this.generateSchemaButton = this.container.getByRole('button', { name: 'Grid to Enum Schema', exact: true }); this.combinationsDialog = page.getByRole('dialog', { name: 'Generate Combinations' }); this.combinationsDialogStrengthSelect = this.combinationsDialog.getByLabel('n'); this.combinationsDialogCancelButton = this.combinationsDialog.getByRole('button', { name: 'Cancel' }); @@ -26,6 +29,8 @@ class TestDataPanelComponent { this.deleteSelectedSchemaRowsButton = this.container.getByRole('button', { name: '- Delete Selected' }); this.selectedSchemaRowIndex = 0; this.overlaySafeActivation = new OverlaySafeActivationComponent(page); + this.confirmDialog = new ConfirmDialogComponent(page); + this.textInputDialog = new TextInputDialogComponent(page); this.schemaEditor = new SchemaEditorComponent(page, { rootSelector: '[data-role="data-population-panel-root"]', fieldMap: { @@ -134,6 +139,15 @@ class TestDataPanelComponent { await expect(this.combinationsDialog).toBeVisible(); } + async openGridToEnumSchemaDialog() { + await this.overlaySafeActivation.activateButton(this.generateSchemaButton); + await this.textInputDialog.expectVisible(); + } + + async submitGridToEnumSchemaLimit(value) { + await this.textInputDialog.submit(value, { submitLabel: /build schema/i }); + } + async cancelGenerateCombinationsDialog() { await this.combinationsDialogCancelButton.click(); await expect(this.combinationsDialog).toBeHidden(); diff --git a/apps/web/src/tests/browser/app/abstractions/components/text-input-dialog.component.js b/apps/web/src/tests/browser/app/abstractions/components/text-input-dialog.component.js index 81b78c1b..70a9d834 100644 --- a/apps/web/src/tests/browser/app/abstractions/components/text-input-dialog.component.js +++ b/apps/web/src/tests/browser/app/abstractions/components/text-input-dialog.component.js @@ -4,7 +4,7 @@ class TextInputDialogComponent { constructor(page) { this.page = page; this.backdrop = page.locator('#text-input-modal-backdrop'); - this.input = this.backdrop.getByRole('textbox'); + this.input = this.backdrop.locator('[data-role="text-input-dialog-field"]'); } async expectVisible() { @@ -15,10 +15,10 @@ class TextInputDialogComponent { await expect(this.backdrop).toBeHidden(); } - async submit(value) { + async submit(value, { submitLabel = /^ok$/i } = {}) { await this.expectVisible(); await this.input.fill(String(value ?? '')); - await this.backdrop.getByRole('button', { name: /^ok$/i }).click(); + await this.backdrop.getByRole('button', { name: submitLabel }).click(); await this.expectHidden(); } } diff --git a/apps/web/src/tests/browser/app/functional/test-data/grid-to-enum-schema.spec.js b/apps/web/src/tests/browser/app/functional/test-data/grid-to-enum-schema.spec.js new file mode 100644 index 00000000..e4eb9f24 --- /dev/null +++ b/apps/web/src/tests/browser/app/functional/test-data/grid-to-enum-schema.spec.js @@ -0,0 +1,58 @@ +const { test } = require('@playwright/test'); +const { openApp, expectNoPageErrors, expect } = require('../../abstractions/helpers/scenario-helpers'); + +async function seedEnumGrid(appPage) { + await appPage.gridEditor.resetTable(); + await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(0); + + await appPage.gridEditor.header.renameColumn('~rename-me', 'Status'); + await appPage.gridEditor.header.addColumnRight('Status', 'Priority'); + + for (let rowIndex = 0; rowIndex < 4; rowIndex += 1) { + await appPage.gridEditor.addRow(); + } + await expect.poll(async () => appPage.gridEditor.renderer.countRows()).toBe(4); + + const statusValues = ['active', 'pending', 'active', 'inactive']; + const priorityValues = ['high', 'low', 'low', 'high']; + + for (let rowIndex = 0; rowIndex < statusValues.length; rowIndex += 1) { + await appPage.gridEditor.renderer.setCellTextByColumnName('Status', rowIndex, statusValues[rowIndex]); + await appPage.gridEditor.renderer.setCellTextByColumnName('Priority', rowIndex, priorityValues[rowIndex]); + } + + await appPage.gridEditor.renderer.waitForGridSettle({ columnName: 'Status', sampleSize: 4 }); +} + +test.describe('7. Test Data Generation', () => { + test('builds enum schema rows from the current grid', async ({ page }) => { + const { appPage, pageErrors } = await openApp(page); + + await seedEnumGrid(appPage); + await appPage.testDataPanel.expand(); + await appPage.testDataPanel.openGridToEnumSchemaDialog(); + await appPage.testDataPanel.submitGridToEnumSchemaLimit(256); + + await expect.poll(async () => appPage.testDataPanel.getSchemaRowCount()).toBe(2); + await expect + .poll(async () => appPage.testDataPanel.getSchemaText()) + .toContain('Status\nenum(active,pending,inactive)'); + await expect(await appPage.testDataPanel.getSchemaCell(0, 'columnName')).toBe('Status'); + await expect(await appPage.testDataPanel.getSchemaCell(1, 'columnName')).toBe('Priority'); + expectNoPageErrors(pageErrors); + }); + + test('confirms truncation when the requested enum limit is lower than current unique counts', async ({ page }) => { + const { appPage, pageErrors } = await openApp(page); + + await seedEnumGrid(appPage); + await appPage.testDataPanel.expand(); + await appPage.testDataPanel.openGridToEnumSchemaDialog(); + await appPage.testDataPanel.submitGridToEnumSchemaLimit(2); + await appPage.testDataPanel.confirmDialog.confirm({ confirmLabel: /truncate schema/i }); + + await expect.poll(async () => appPage.testDataPanel.getSchemaText()).toContain('Status\nenum(active,pending)'); + await expect.poll(async () => appPage.testDataPanel.getSchemaText()).not.toContain('inactive'); + expectNoPageErrors(pageErrors); + }); +}); diff --git a/docs-src/docs/040-test-data/015-data-grid-editable.md b/docs-src/docs/040-test-data/015-data-grid-editable.md index 9d678d2e..96eda067 100644 --- a/docs-src/docs/040-test-data/015-data-grid-editable.md +++ b/docs-src/docs/040-test-data/015-data-grid-editable.md @@ -32,6 +32,40 @@ The definition grid acts as a schema/template: Generated rows are inserted into the main editable data grid. +## Grid to Enum Schema + +The `Grid to Enum Schema` action scans the current main data grid and builds a schema definition from the values already in the table. + +Use this when you already have example rows in the editable grid and want to quickly turn those values into `Enum` generation rules. + +The generated schema uses: + +- one schema row per grid column +- the current column header as the schema row name +- `Enum` as the source type for every generated row +- unique values collected in first-seen row order + +### How it works + +1. Open the `Test Data` section in `app.html`. +2. Press `Grid to Enum Schema`. +3. Enter a maximum number of enum values to keep for each column. +4. Confirm truncation if any column has more unique values than the chosen limit. +5. Review the generated schema and press `Generate` or `Generate Pairwise` as needed. + +The limit prompt shows the largest unique-value count found in the scanned grid and uses a numeric input field to reduce entry mistakes. + +The default value is: + +- the largest column unique-value count when it is `256` or less +- `256` when the largest column has more than `256` unique values + +If the chosen limit is lower than the number of unique values in one or more columns, then AnyWayData will ask for confirmation before truncating the schema. Truncation keeps the first values seen in current grid row order. + +When accepted, the generated enum schema replaces the existing schema definition in the Test Data editor. The schema grid, schema text area, validation state, and pairwise button visibility are all refreshed automatically. + +If the grid is empty, or no usable values can be extracted, then the existing schema is left unchanged. + ## Generate Modes in the Grid When generating in `app.html`, you can choose whether generation replaces data or amends existing rows: @@ -103,6 +137,3 @@ The schema in the Column Definition Data Grid will be used to generate the data. All data generation happens in the browser so the amount of data you can generate is limited only by the performance and memory of your computer. - - - diff --git a/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-controller.js b/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-controller.js index d39c6e00..25953e3c 100644 --- a/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-controller.js +++ b/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-controller.js @@ -8,6 +8,7 @@ class DataPopulationPanelController { rowCountProps: { ...(props.rowCountProps || {}) }, actionIds: { ...(props.actionIds || {}) }, schemaDefinitionProps: { ...(props.schemaDefinitionProps || {}) }, + generateSchemaBusy: props.generateSchemaBusy === true, }; } @@ -24,6 +25,7 @@ class DataPopulationPanelController { schemaDefinitionProps: nextProps.schemaDefinitionProps ? { ...this.state.schemaDefinitionProps, ...nextProps.schemaDefinitionProps } : this.state.schemaDefinitionProps, + generateSchemaBusy: nextProps.generateSchemaBusy ?? this.state.generateSchemaBusy, }; } diff --git a/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-view.js b/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-view.js index 967ff766..937eb421 100644 --- a/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-view.js +++ b/packages/core-ui/js/gui_components/app/data-population-panel/data-population-panel-view.js @@ -46,10 +46,12 @@ class DataPopulationPanelView { modeOptions: state.modeOptions, rowCountProps: state.rowCountProps, actionIds: state.actionIds, + generateSchemaBusy: state.generateSchemaBusy, }, callbacks: { onGenerate: this.callbacks.onGenerate, onGeneratePairwise: this.callbacks.onGeneratePairwise, + onGenerateSchemaFromGrid: this.callbacks.onGenerateSchemaFromGrid, onModeChange: (mode) => this.controller.handleModeChange(mode), }, }); @@ -79,6 +81,7 @@ class DataPopulationPanelView { modeOptions: state.modeOptions, rowCountProps: state.rowCountProps, actionIds: state.actionIds, + generateSchemaBusy: state.generateSchemaBusy, }); this.schemaPanel?.update?.({ className: 'test-data-schema-edit-zone shared-schema-section', @@ -123,6 +126,10 @@ class DataPopulationPanelView { this.toolbar?.setGeneratePairwiseBusy?.(isBusy); } + setGenerateSchemaBusy(isBusy) { + this.toolbar?.setGenerateSchemaBusy?.(isBusy); + } + getSchemaDefinition() { return this.schemaPanel?.getSchemaDefinition?.() || null; } diff --git a/packages/core-ui/js/gui_components/app/data-population-panel/index.js b/packages/core-ui/js/gui_components/app/data-population-panel/index.js index 9f52668e..dfef5385 100644 --- a/packages/core-ui/js/gui_components/app/data-population-panel/index.js +++ b/packages/core-ui/js/gui_components/app/data-population-panel/index.js @@ -72,6 +72,10 @@ function createDataPopulationPanelComponent({ root, props = {}, services = {}, c setGeneratePairwiseBusy(isBusy) { view.setGeneratePairwiseBusy(isBusy); }, + setGenerateSchemaBusy(isBusy) { + controller.updateProps({ generateSchemaBusy: isBusy === true }); + view.setGenerateSchemaBusy(isBusy); + }, validateSchemaRows({ syncFromText = true } = {}) { if (syncFromText) { const isTextMode = view.getSchemaDefinition()?.getState?.()?.isTextMode === true; @@ -90,6 +94,9 @@ function createDataPopulationPanelComponent({ root, props = {}, services = {}, c syncSchemaTextFromRows() { return view.getSchemaDefinition()?.syncTextFromRows?.(); }, + replaceSchemaRows(rows) { + return view.getSchemaDefinition()?.replaceRows?.(rows); + }, insertSampleSchema() { return view.getSchemaDefinition()?.insertSampleSchema?.(); }, diff --git a/packages/core-ui/js/gui_components/app/population-actions/index.js b/packages/core-ui/js/gui_components/app/population-actions/index.js index 272be3df..95ad0bf9 100644 --- a/packages/core-ui/js/gui_components/app/population-actions/index.js +++ b/packages/core-ui/js/gui_components/app/population-actions/index.js @@ -44,6 +44,10 @@ function createPopulationActionsComponent({ controller.updateProps({ generatePairwiseBusy: isBusy === true }); view.render(); }, + setGenerateSchemaBusy(isBusy) { + controller.updateProps({ generateSchemaBusy: isBusy === true }); + view.render(); + }, getState() { return controller.getState(); }, diff --git a/packages/core-ui/js/gui_components/app/population-actions/population-actions-controller.js b/packages/core-ui/js/gui_components/app/population-actions/population-actions-controller.js index 2043c90d..24ef42f0 100644 --- a/packages/core-ui/js/gui_components/app/population-actions/population-actions-controller.js +++ b/packages/core-ui/js/gui_components/app/population-actions/population-actions-controller.js @@ -5,17 +5,22 @@ class PopulationActionsController { pairwiseVisible: props.pairwiseVisible === true, generateBusy: props.generateBusy === true, generatePairwiseBusy: props.generatePairwiseBusy === true, + generateSchemaBusy: props.generateSchemaBusy === true, generateLabel: props.generateLabel || 'Generate', generatePairwiseLabel: props.generatePairwiseLabel || 'Generate Pairwise', + generateSchemaLabel: props.generateSchemaLabel || 'Grid to Enum Schema', generateHelpHtml: props.generateHelpHtml || '', generatePairwiseHelpHtml: props.generatePairwiseHelpHtml || '', + generateSchemaHelpHtml: props.generateSchemaHelpHtml || '', generateHelpLabel: props.generateHelpLabel || 'Show generate help', generatePairwiseHelpLabel: props.generatePairwiseHelpLabel || 'Show pairwise generation help', + generateSchemaHelpLabel: props.generateSchemaHelpLabel || 'Show grid to schema help', statusVisible: props.statusVisible === true, roleNames: { generateButton: props.roleNames?.generateButton || 'generate-button', generatePairwiseButton: props.roleNames?.generatePairwiseButton || 'generate-pairwise-button', generatePairwiseWrapper: props.roleNames?.generatePairwiseWrapper || 'generate-pairwise-button-wrapper', + generateSchemaButton: props.roleNames?.generateSchemaButton || 'generate-schema-button', status: props.roleNames?.status || 'population-status', }, }; @@ -27,12 +32,16 @@ class PopulationActionsController { pairwiseVisible: nextProps.pairwiseVisible ?? this.state.pairwiseVisible, generateBusy: nextProps.generateBusy ?? this.state.generateBusy, generatePairwiseBusy: nextProps.generatePairwiseBusy ?? this.state.generatePairwiseBusy, + generateSchemaBusy: nextProps.generateSchemaBusy ?? this.state.generateSchemaBusy, generateLabel: nextProps.generateLabel ?? this.state.generateLabel, generatePairwiseLabel: nextProps.generatePairwiseLabel ?? this.state.generatePairwiseLabel, + generateSchemaLabel: nextProps.generateSchemaLabel ?? this.state.generateSchemaLabel, generateHelpHtml: nextProps.generateHelpHtml ?? this.state.generateHelpHtml, generatePairwiseHelpHtml: nextProps.generatePairwiseHelpHtml ?? this.state.generatePairwiseHelpHtml, + generateSchemaHelpHtml: nextProps.generateSchemaHelpHtml ?? this.state.generateSchemaHelpHtml, generateHelpLabel: nextProps.generateHelpLabel ?? this.state.generateHelpLabel, generatePairwiseHelpLabel: nextProps.generatePairwiseHelpLabel ?? this.state.generatePairwiseHelpLabel, + generateSchemaHelpLabel: nextProps.generateSchemaHelpLabel ?? this.state.generateSchemaHelpLabel, statusVisible: nextProps.statusVisible ?? this.state.statusVisible, roleNames: nextProps.roleNames ? { @@ -54,6 +63,10 @@ class PopulationActionsController { handleGeneratePairwise() { this.callbacks.onGeneratePairwise?.(); } + + handleGenerateSchemaFromGrid() { + this.callbacks.onGenerateSchemaFromGrid?.(); + } } export { PopulationActionsController }; diff --git a/packages/core-ui/js/gui_components/app/population-actions/population-actions-view.js b/packages/core-ui/js/gui_components/app/population-actions/population-actions-view.js index 0caf027c..71339b16 100644 --- a/packages/core-ui/js/gui_components/app/population-actions/population-actions-view.js +++ b/packages/core-ui/js/gui_components/app/population-actions/population-actions-view.js @@ -11,6 +11,7 @@ class PopulationActionsView { this.services = services; this.handleGenerate = () => this.controller.handleGenerate(); this.handleGeneratePairwise = () => this.controller.handleGeneratePairwise(); + this.handleGenerateSchemaFromGrid = () => this.controller.handleGenerateSchemaFromGrid(); } buildOptionalIdAttr(id) { @@ -43,12 +44,14 @@ class PopulationActionsView { this.generatePairwiseButton = this.root.querySelector( `[data-role="${this.getRoleName('generatePairwiseButton')}"]` ); + this.generateSchemaButton = this.root.querySelector(`[data-role="${this.getRoleName('generateSchemaButton')}"]`); this.generatePairwiseWrapper = this.root.querySelector( `[data-role="${this.getRoleName('generatePairwiseWrapper')}"]` ); this.generateButton?.addEventListener('click', this.handleGenerate); this.generatePairwiseButton?.addEventListener('click', this.handleGeneratePairwise); + this.generateSchemaButton?.addEventListener('click', this.handleGenerateSchemaFromGrid); this.render(); } @@ -85,6 +88,16 @@ class PopulationActionsView { ${escapeHtml(state.generatePairwiseLabel)} + ${ state.statusVisible ? ` @@ -116,6 +129,11 @@ class PopulationActionsView { this.generatePairwiseButton.setAttribute('aria-disabled', state.generatePairwiseBusy === true ? 'true' : 'false'); this.generatePairwiseButton.setAttribute('aria-busy', state.generatePairwiseBusy === true ? 'true' : 'false'); } + if (this.generateSchemaButton) { + this.generateSchemaButton.disabled = state.generateSchemaBusy === true; + this.generateSchemaButton.setAttribute('aria-disabled', state.generateSchemaBusy === true ? 'true' : 'false'); + this.generateSchemaButton.setAttribute('aria-busy', state.generateSchemaBusy === true ? 'true' : 'false'); + } if (this.generatePairwiseWrapper) { this.generatePairwiseWrapper.style.display = state.pairwiseVisible ? 'inline-flex' : 'none'; } @@ -125,6 +143,7 @@ class PopulationActionsView { destroy() { this.generateButton?.removeEventListener('click', this.handleGenerate); this.generatePairwiseButton?.removeEventListener('click', this.handleGeneratePairwise); + this.generateSchemaButton?.removeEventListener('click', this.handleGenerateSchemaFromGrid); this.root.replaceChildren(); } } diff --git a/packages/core-ui/js/gui_components/app/test-data-grid/controller/test-data-grid-controller.js b/packages/core-ui/js/gui_components/app/test-data-grid/controller/test-data-grid-controller.js index 9d4d735e..7ac86d1e 100644 --- a/packages/core-ui/js/gui_components/app/test-data-grid/controller/test-data-grid-controller.js +++ b/packages/core-ui/js/gui_components/app/test-data-grid/controller/test-data-grid-controller.js @@ -48,6 +48,8 @@ import { createDataPopulationPanelComponent } from '../../data-population-panel/ import { resolveDocumentObj } from '../../../shared/dom/default-objects.js'; import { createCombinationsDialogComponent } from '../../../shared/combinations-dialog/index.js'; import { createConfirmDialogService } from '../../../shared/dialog-services/confirm-dialog-service.js'; +import { createTextInputDialogService } from '../../../shared/dialog-services/text-input-dialog-service.js'; +import { DEFAULT_ENUM_LIMIT, createEnumSchemaRowsFromGrid, normaliseEnumLimit } from '../schema/grid-to-enum-schema.js'; import { faker } from 'https://cdn.skypack.dev/@faker-js/faker@v9.7.0'; @@ -70,6 +72,8 @@ function createTestDataGenerationPanelManager({ createDataPopulationPanelComponentFn = createDataPopulationPanelComponent, createTestDataUiStatusServiceFn = createTestDataUiStatusService, createCombinationsDialogComponentFn = createCombinationsDialogComponent, + createConfirmDialogServiceFn = createConfirmDialogService, + createTextInputDialogServiceFn = createTextInputDialogService, } = {}) { const state = { debouncer: new DebouncerClass(), @@ -82,6 +86,7 @@ function createTestDataGenerationPanelManager({ actionAdapter: null, uiStatusService: null, combinationsDialog: null, + textInputDialogService: null, }; function showTestDataSchemaError(message) { @@ -149,7 +154,7 @@ function createTestDataGenerationPanelManager({ function createGenerationService() { const resolvedRandExpClass = getRandExpClass(); const statusServiceApi = getStatusServiceApi(); - const confirmDialogService = createConfirmDialogService({ documentObj: getResolvedDocument() }); + const confirmDialogService = createConfirmDialogServiceFn({ documentObj: getResolvedDocument() }); return createTestDataGenerationServiceFn({ schemaTextToDataRules: schemaTextToDataRulesFn, schemaRowsToSpec: (schemaRows) => @@ -188,6 +193,109 @@ function createTestDataGenerationPanelManager({ }); } + function ensureTextInputDialogService() { + if (!state.textInputDialogService) { + state.textInputDialogService = createTextInputDialogServiceFn({ documentObj: getResolvedDocument() }); + } + return state.textInputDialogService; + } + + async function generateEnumSchemaFromGrid() { + const statusServiceApi = getStatusServiceApi(); + const gridExtras = getMainGridExtras(); + const schemaDefinition = state.dataPopulationPanel?.getSchemaDefinition?.(); + const createBlankRow = state.dataPopulationPanel?.getState?.()?.schemaDefinitionProps?.createBlankRow; + + if (!gridExtras?.getGridAsGenericDataTable || !schemaDefinition || typeof createBlankRow !== 'function') { + showTestDataSchemaError('Grid to schema is not available.'); + statusServiceApi.setTestDataStatus('Unable to build schema from the grid.', { + severity: 'error', + dismissable: true, + }); + return false; + } + + try { + state.dataPopulationPanel?.setGenerateSchemaBusy?.(true); + statusServiceApi.setTestDataLoadingStatus('Scanning grid for enum schema...'); + const dataTable = await Promise.resolve(gridExtras.getGridAsGenericDataTable()); + const initialSummary = createEnumSchemaRowsFromGrid({ + dataTable, + maxEnumValues: DEFAULT_ENUM_LIMIT, + }); + + if (initialSummary.usableColumns.length === 0) { + showTestDataSchemaError('No non-empty column values were found to build enum schema rows.'); + statusServiceApi.setTestDataStatus('No enum schema rows generated from the current grid.', { + severity: 'warning', + dismissable: true, + }); + return false; + } + + const suggestedLimit = Math.min(initialSummary.maxUniqueValueCount || DEFAULT_ENUM_LIMIT, DEFAULT_ENUM_LIMIT); + const requestedLimit = await ensureTextInputDialogService().requestTextInput({ + title: 'Grid to Enum Schema', + message: `- largest Column has ${initialSummary.maxUniqueValueCount} unique values`, + label: 'Limit imported enum(s) to max size of', + initialValue: String(suggestedLimit), + okLabel: 'Build Schema', + cancelLabel: 'Cancel', + inputType: 'number', + min: 1, + step: 1, + }); + if (requestedLimit === null) { + statusServiceApi.setTestDataStatus('Grid to enum schema cancelled.', { + severity: 'warning', + dismissable: true, + }); + return false; + } + + const maxEnumValues = normaliseEnumLimit(requestedLimit); + const summary = createEnumSchemaRowsFromGrid({ + dataTable, + maxEnumValues, + createBlankRow, + }); + + if (summary.truncatedColumnCount > 0) { + const confirmDialogService = createConfirmDialogServiceFn({ documentObj: getResolvedDocument() }); + const shouldTruncate = await confirmDialogService.requestConfirm({ + title: 'Confirm enum truncation', + message: `Enum limit ${maxEnumValues} is less than current values. Truncate to first ${maxEnumValues} values for affected columns?`, + okLabel: 'Truncate Schema', + cancelLabel: 'Cancel', + }); + confirmDialogService.destroy(); + if (!shouldTruncate) { + statusServiceApi.setTestDataStatus('Grid to enum schema cancelled.', { + severity: 'warning', + dismissable: true, + }); + return false; + } + } + + const validation = state.dataPopulationPanel?.replaceSchemaRows?.(summary.rows) || { errors: [] }; + if (validation.errors?.length > 0) { + statusServiceApi.setTestDataStatus('Schema replacement completed with validation warnings.', { + severity: 'warning', + dismissable: true, + }); + } else { + statusServiceApi.setTestDataStatus( + `Created ${summary.rows.length} enum schema rows${summary.truncatedColumnCount > 0 ? ` with truncation in ${summary.truncatedColumnCount} column${summary.truncatedColumnCount === 1 ? '' : 's'}` : ''}.`, + { dismissable: true } + ); + } + return true; + } finally { + state.dataPopulationPanel?.setGenerateSchemaBusy?.(false); + } + } + function ensureCombinationsDialog(resolvedDocument) { if (state.combinationsDialog) { return state.combinationsDialog; @@ -229,7 +337,9 @@ function createTestDataGenerationPanelManager({ state.dataPopulationPanel?.destroy?.(); state.uiStatusService?.destroy?.(); state.combinationsDialog?.destroy?.(); + state.textInputDialogService?.destroy?.(); state.combinationsDialog = null; + state.textInputDialogService = null; state.debouncer?.clear?.(); identifyFakerCommandsFn(faker); @@ -308,6 +418,7 @@ function createTestDataGenerationPanelManager({ callbacks: { onGenerate: () => state.actionAdapter.generateTestData(), onGeneratePairwise: () => openGenerateCombinationsDialog(), + onGenerateSchemaFromGrid: generateEnumSchemaFromGrid, onModeChange: applyModeDefaultRowCount, schemaDefinition: { onSchemaError: (message) => state.schemaTextSyncState?.schemaErrorDisplay?.show?.(message), @@ -331,7 +442,9 @@ function createTestDataGenerationPanelManager({ state.dataPopulationPanel?.destroy?.(); state.uiStatusService?.destroy?.(); state.combinationsDialog?.destroy?.(); + state.textInputDialogService?.destroy?.(); state.combinationsDialog = null; + state.textInputDialogService = null; state.debouncer?.clear?.(); }, }; diff --git a/packages/core-ui/js/gui_components/app/test-data-grid/schema/grid-to-enum-schema.js b/packages/core-ui/js/gui_components/app/test-data-grid/schema/grid-to-enum-schema.js new file mode 100644 index 00000000..04dec0ce --- /dev/null +++ b/packages/core-ui/js/gui_components/app/test-data-grid/schema/grid-to-enum-schema.js @@ -0,0 +1,86 @@ +import { SOURCE_TYPE_ENUM } from '../../../shared/schema-row-rule-mapper.js'; + +const DEFAULT_ENUM_LIMIT = 256; + +function normaliseEnumLimit(limit, { defaultLimit = DEFAULT_ENUM_LIMIT } = {}) { + const parsed = Number.parseInt(limit, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return defaultLimit; + } + return parsed; +} + +function normaliseGridCellValue(value) { + return String(value ?? '').trim(); +} + +function serialiseEnumSchemaValue(value) { + const normalised = normaliseGridCellValue(value); + if (!/[,"\r\n]/.test(normalised)) { + return normalised; + } + return `"${normalised.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function buildGridEnumSchemaSummary({ dataTable, maxEnumValues = DEFAULT_ENUM_LIMIT } = {}) { + const headers = Array.isArray(dataTable?.getHeaders?.()) ? dataTable.getHeaders() : []; + const rowCount = Number.parseInt(dataTable?.getRowCount?.() ?? 0, 10) || 0; + const safeLimit = normaliseEnumLimit(maxEnumValues); + + const columns = headers.map((header, headerIndex) => { + const uniqueValues = []; + const seenValues = new Set(); + + for (let rowIndex = 0; rowIndex < rowCount; rowIndex += 1) { + const cellValue = normaliseGridCellValue(dataTable?.getCell?.(rowIndex, headerIndex)); + if (!cellValue || seenValues.has(cellValue)) { + continue; + } + seenValues.add(cellValue); + uniqueValues.push(cellValue); + } + + const keptValues = uniqueValues.slice(0, safeLimit); + + return { + header: String(header ?? '').trim(), + uniqueValues, + uniqueValueCount: uniqueValues.length, + truncated: uniqueValues.length > keptValues.length, + keptValues, + }; + }); + + const usableColumns = columns.filter((column) => column.header.length > 0 && column.uniqueValueCount > 0); + const maxUniqueValueCount = usableColumns.reduce((largest, column) => Math.max(largest, column.uniqueValueCount), 0); + const truncatedColumnCount = usableColumns.filter((column) => column.truncated).length; + + return { + maxEnumValues: safeLimit, + maxUniqueValueCount, + truncatedColumnCount, + columns, + usableColumns, + }; +} + +function createEnumSchemaRowsFromGrid({ dataTable, maxEnumValues = DEFAULT_ENUM_LIMIT, createBlankRow } = {}) { + const summary = buildGridEnumSchemaSummary({ dataTable, maxEnumValues }); + const rows = summary.usableColumns.map((column) => ({ + ...(typeof createBlankRow === 'function' ? createBlankRow() : {}), + name: column.header, + sourceType: SOURCE_TYPE_ENUM, + command: '', + params: '', + value: column.keptValues.map(serialiseEnumSchemaValue).join(','), + comments: '', + leadingTextLines: [], + })); + + return { + ...summary, + rows, + }; +} + +export { DEFAULT_ENUM_LIMIT, buildGridEnumSchemaSummary, createEnumSchemaRowsFromGrid, normaliseEnumLimit }; diff --git a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/index.js b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/index.js index c6510c76..917e0c2a 100644 --- a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/index.js +++ b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/index.js @@ -67,6 +67,10 @@ function createTestDataPopulationToolbarComponent({ controller.updateProps({ generatePairwiseBusy: isBusy === true }); view.setGeneratePairwiseBusy(isBusy); }, + setGenerateSchemaBusy(isBusy) { + controller.updateProps({ generateSchemaBusy: isBusy === true }); + view.setGenerateSchemaBusy(isBusy); + }, getState() { return controller.getState(); }, diff --git a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-controller.js b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-controller.js index fb9028e4..0a236f40 100644 --- a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-controller.js +++ b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-controller.js @@ -9,6 +9,7 @@ class TestDataPopulationToolbarController { actionIds: { ...(props.actionIds || {}) }, generateBusy: props.generateBusy === true, generatePairwiseBusy: props.generatePairwiseBusy === true, + generateSchemaBusy: props.generateSchemaBusy === true, }; } @@ -24,6 +25,7 @@ class TestDataPopulationToolbarController { : this.state.rowCountProps, generateBusy: nextProps.generateBusy ?? this.state.generateBusy, generatePairwiseBusy: nextProps.generatePairwiseBusy ?? this.state.generatePairwiseBusy, + generateSchemaBusy: nextProps.generateSchemaBusy ?? this.state.generateSchemaBusy, }; } diff --git a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-view.js b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-view.js index 81b26daf..638918f9 100644 --- a/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-view.js +++ b/packages/core-ui/js/gui_components/app/test-data-population-toolbar/test-data-population-toolbar-view.js @@ -10,6 +10,10 @@ const GENERATE_PAIRWISE_TO_GRID_HELP_HTML = ` `; +const GRID_TO_SCHEMA_HELP_HTML = ` +Scan the current grid and build an enum-only schema from the visible column values.
+`; + class TestDataPopulationToolbarView { constructor({ root, controller, documentObj, services = {}, callbacks = {} } = {}) { this.root = root; @@ -60,11 +64,15 @@ class TestDataPopulationToolbarView { generateHelpHtml: GENERATE_TO_GRID_HELP_HTML, generatePairwiseHelpHtml: GENERATE_PAIRWISE_TO_GRID_HELP_HTML, generatePairwiseHelpLabel: 'Show combination generation help', + generateSchemaLabel: 'Grid to Enum Schema', + generateSchemaHelpHtml: GRID_TO_SCHEMA_HELP_HTML, + generateSchemaHelpLabel: 'Show grid to enum schema help', statusVisible: true, }, callbacks: { onGenerate: this.callbacks.onGenerate, onGeneratePairwise: this.callbacks.onGeneratePairwise, + onGenerateSchemaFromGrid: this.callbacks.onGenerateSchemaFromGrid, }, }); @@ -99,7 +107,11 @@ class TestDataPopulationToolbarView { generateHelpHtml: GENERATE_TO_GRID_HELP_HTML, generatePairwiseHelpHtml: GENERATE_PAIRWISE_TO_GRID_HELP_HTML, generatePairwiseHelpLabel: 'Show combination generation help', + generateSchemaLabel: 'Grid to Enum Schema', + generateSchemaHelpHtml: GRID_TO_SCHEMA_HELP_HTML, + generateSchemaHelpLabel: 'Show grid to enum schema help', statusVisible: true, + generateSchemaBusy: state.generateSchemaBusy, }); this.populationModeSelector?.update?.({ name: 'testDataGenerationMode', @@ -147,6 +159,10 @@ class TestDataPopulationToolbarView { setGeneratePairwiseBusy(isBusy) { this.populationActions?.setGeneratePairwiseBusy?.(isBusy); } + + setGenerateSchemaBusy(isBusy) { + this.populationActions?.setGenerateSchemaBusy?.(isBusy); + } } export { TestDataPopulationToolbarView }; diff --git a/packages/core-ui/js/gui_components/shared/modal-text-input.js b/packages/core-ui/js/gui_components/shared/modal-text-input.js index 2e5433db..a9eb379c 100644 --- a/packages/core-ui/js/gui_components/shared/modal-text-input.js +++ b/packages/core-ui/js/gui_components/shared/modal-text-input.js @@ -18,6 +18,8 @@ function ensureModalElements(documentObj = getDefaultDocumentObj()) { backdrop.innerHTML = `Generate to grid.
', generatePairwiseLabel: 'Generate Combinations', generatePairwiseHelpHtml: 'Generate n-wise combinations to grid.
', + generateSchemaHelpHtml: 'Generate enum-only schema from grid values.
', statusVisible: true, }, callbacks: { onGenerate, onGeneratePairwise, + onGenerateSchemaFromGrid, }, }); const root = document.getElementById('root'); const generateButton = root.querySelector('[data-role="generate-button"]'); const generatePairwiseButton = root.querySelector('[data-role="generate-pairwise-button"]'); + const generateSchemaButton = root.querySelector('[data-role="generate-schema-button"]'); const generatePairwiseWrapper = root.querySelector('[data-role="generate-pairwise-button-wrapper"]'); const helpButtons = root.querySelectorAll('[data-help-role="help-icon"]'); const status = root.querySelector('[data-role="population-status"]'); @@ -58,12 +62,15 @@ describe('PopulationActions', () => { expect(document.getElementById('generatedata')).toBeNull(); expect(document.getElementById('generateallpairs')).toBeNull(); expect(document.getElementById('testdata-status')).toBeNull(); - expect(helpButtons).toHaveLength(2); + expect(helpButtons).toHaveLength(3); expect(helpButtons[0].getAttribute('data-help-text')).toContain('Generate to grid.'); expect(generatePairwiseButton.textContent).toContain('Generate Combinations'); expect(helpButtons[1].getAttribute('data-help-text')).toContain('Generate n-wise combinations to grid.'); + expect(generateSchemaButton.textContent).toContain('Grid to Enum Schema'); + expect(helpButtons[2].getAttribute('data-help-text')).toContain('enum-only schema'); expect(generateButton.querySelector('svg.shared-file-action-icon')).not.toBeNull(); expect(generatePairwiseButton.querySelector('svg.shared-file-action-icon')).not.toBeNull(); + expect(generateSchemaButton.querySelector('svg.shared-file-action-icon')).not.toBeNull(); expect(generatePairwiseWrapper.style.display).toBe('none'); @@ -81,12 +88,18 @@ describe('PopulationActions', () => { component.setGeneratePairwiseBusy(false); expect(generateButton.getAttribute('aria-disabled')).toBe('false'); expect(generatePairwiseButton.getAttribute('aria-disabled')).toBe('false'); + component.setGenerateSchemaBusy(true); + expect(generateSchemaButton.disabled).toBe(true); + component.setGenerateSchemaBusy(false); + expect(generateSchemaButton.getAttribute('aria-disabled')).toBe('false'); generateButton.click(); generatePairwiseButton.click(); + generateSchemaButton.click(); expect(onGenerate).toHaveBeenCalled(); expect(onGeneratePairwise).toHaveBeenCalled(); + expect(onGenerateSchemaFromGrid).toHaveBeenCalled(); component.destroy(); }); diff --git a/packages/core-ui/src/tests/app/test-data-population-toolbar.test.js b/packages/core-ui/src/tests/app/test-data-population-toolbar.test.js index f1700579..e5b56ebd 100644 --- a/packages/core-ui/src/tests/app/test-data-population-toolbar.test.js +++ b/packages/core-ui/src/tests/app/test-data-population-toolbar.test.js @@ -30,6 +30,7 @@ describe('TestDataPopulationToolbar', () => { test('composes actions, row count, and mode selector', () => { const onGenerate = jest.fn(); const onGeneratePairwise = jest.fn(); + const onGenerateSchemaFromGrid = jest.fn(); const onModeChange = jest.fn(); const component = createTestDataPopulationToolbarComponent({ @@ -52,6 +53,7 @@ describe('TestDataPopulationToolbar', () => { callbacks: { onGenerate, onGeneratePairwise, + onGenerateSchemaFromGrid, onModeChange, }, }); @@ -59,6 +61,7 @@ describe('TestDataPopulationToolbar', () => { const toolbarRoot = document.querySelector('[data-role="test-data-population-toolbar-root"]'); const toolbarQueries = within(toolbarRoot); const generateButton = toolbarQueries.getByRole('button', { name: /^generate$/i }); + const generateSchemaButton = toolbarQueries.getByRole('button', { name: /^grid to enum schema$/i }); const rowCountInput = toolbarQueries.getByRole('spinbutton', { name: 'How Many?' }); const generatePairwiseWrapper = toolbarRoot.querySelector('[data-role="generate-pairwise-button-wrapper"]'); @@ -77,14 +80,19 @@ describe('TestDataPopulationToolbar', () => { component.setGenerateBusy(true); component.setGeneratePairwiseBusy(true); + component.setGenerateSchemaBusy(true); expect(generateButton.disabled).toBe(true); expect(generateCombinationsButton.disabled).toBe(true); + expect(generateSchemaButton.disabled).toBe(true); expect(generateButton.getAttribute('aria-disabled')).toBe('true'); component.setGenerateBusy(false); component.setGeneratePairwiseBusy(false); + component.setGenerateSchemaBusy(false); generateButton.click(); + generateSchemaButton.click(); expect(onGenerate).toHaveBeenCalled(); + expect(onGenerateSchemaFromGrid).toHaveBeenCalled(); const amendSelected = toolbarQueries.getByRole('radio', { name: 'Amend Selected' }); amendSelected.checked = true; diff --git a/packages/core-ui/src/tests/grid/controller/test-data-grid-controller.test.js b/packages/core-ui/src/tests/grid/controller/test-data-grid-controller.test.js index 9427f218..e212332c 100644 --- a/packages/core-ui/src/tests/grid/controller/test-data-grid-controller.test.js +++ b/packages/core-ui/src/tests/grid/controller/test-data-grid-controller.test.js @@ -58,9 +58,26 @@ describe('test data grid controller', () => { getMode: jest.fn(() => 'amend-selected'), setPairwiseVisible: jest.fn(), setRowCountValue: jest.fn(), + setGenerateSchemaBusy: jest.fn(), validateSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), syncSchemaTextFromRows: jest.fn(), insertSampleSchema: jest.fn(), + getSchemaDefinition: jest.fn(() => ({ replaceRows: jest.fn() })), + getState: jest.fn(() => ({ + schemaDefinitionProps: { + createBlankRow: () => ({ + id: 'generated-row', + name: '', + sourceType: 'regex', + command: '', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }), + }, + })), + replaceSchemaRows: jest.fn(() => ({ rows: [{ name: 'Status' }], errors: [] })), }; createTestDataGenerationServiceFn.mockImplementation((options) => { generationServiceOptions.push(options); @@ -151,9 +168,17 @@ describe('test data grid controller', () => { getMode: jest.fn(() => 'new-table'), setPairwiseVisible: jest.fn(), setRowCountValue, + setGenerateSchemaBusy: jest.fn(), validateSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), syncSchemaTextFromRows: jest.fn(), insertSampleSchema: jest.fn(), + getSchemaDefinition: jest.fn(() => ({ replaceRows: jest.fn() })), + getState: jest.fn(() => ({ + schemaDefinitionProps: { + createBlankRow: () => ({ id: 'row-1', name: '', sourceType: 'regex', command: '', params: '', value: '' }), + }, + })), + replaceSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), }; const createDataPopulationPanelComponentFn = jest.fn(({ callbacks }) => { panel.callbacks = callbacks; @@ -204,6 +229,7 @@ describe('test data grid controller', () => { getMode: jest.fn(() => 'new-table'), setPairwiseVisible: jest.fn(), setRowCountValue: jest.fn(), + setGenerateSchemaBusy: jest.fn(), })), }); @@ -225,9 +251,17 @@ describe('test data grid controller', () => { getMode: jest.fn(() => 'new-table'), setPairwiseVisible: jest.fn(), setRowCountValue: jest.fn(), + setGenerateSchemaBusy: jest.fn(), validateSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), syncSchemaTextFromRows: jest.fn(), insertSampleSchema: jest.fn(), + getSchemaDefinition: jest.fn(() => ({ replaceRows: jest.fn() })), + getState: jest.fn(() => ({ + schemaDefinitionProps: { + createBlankRow: () => ({ id: 'row-1', name: '', sourceType: 'regex', command: '', params: '', value: '' }), + }, + })), + replaceSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), })); const control = createTestDataGenerationPanelManager({ @@ -273,9 +307,17 @@ describe('test data grid controller', () => { setRowCountValue: jest.fn(), setGenerateBusy: jest.fn(), setGeneratePairwiseBusy: jest.fn(), + setGenerateSchemaBusy: jest.fn(), validateSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), syncSchemaTextFromRows: jest.fn(), insertSampleSchema: jest.fn(), + getSchemaDefinition: jest.fn(() => ({ replaceRows: jest.fn() })), + getState: jest.fn(() => ({ + schemaDefinitionProps: { + createBlankRow: () => ({ id: 'row-1', name: '', sourceType: 'regex', command: '', params: '', value: '' }), + }, + })), + replaceSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), }; const panelB = { destroy: jest.fn(), @@ -285,9 +327,17 @@ describe('test data grid controller', () => { setRowCountValue: jest.fn(), setGenerateBusy: jest.fn(), setGeneratePairwiseBusy: jest.fn(), + setGenerateSchemaBusy: jest.fn(), validateSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), syncSchemaTextFromRows: jest.fn(), insertSampleSchema: jest.fn(), + getSchemaDefinition: jest.fn(() => ({ replaceRows: jest.fn() })), + getState: jest.fn(() => ({ + schemaDefinitionProps: { + createBlankRow: () => ({ id: 'row-1', name: '', sourceType: 'regex', command: '', params: '', value: '' }), + }, + })), + replaceSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), }; const generationServiceOptions = []; const createTestDataGenerationServiceFn = jest.fn((options) => { @@ -371,4 +421,303 @@ describe('test data grid controller', () => { global.document = originalDocument; } }); + + test('builds enum schema rows from the current grid and replaces the schema after the user accepts', async () => { + const requestTextInput = jest.fn(async () => '2'); + const textInputDialogService = { + requestTextInput, + destroy: jest.fn(), + }; + const confirmDialogService = { + requestConfirm: jest.fn(async () => true), + destroy: jest.fn(), + }; + const panel = { + destroy: jest.fn(), + getMode: jest.fn(() => 'new-table'), + setPairwiseVisible: jest.fn(), + setRowCountValue: jest.fn(), + setGenerateSchemaBusy: jest.fn(), + validateSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), + syncSchemaTextFromRows: jest.fn(), + insertSampleSchema: jest.fn(), + getSchemaDefinition: jest.fn(() => ({ replaceRows: jest.fn() })), + getState: jest.fn(() => ({ + schemaDefinitionProps: { + createBlankRow: () => ({ + id: 'generated-row', + name: '', + sourceType: 'regex', + command: '', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }), + }, + })), + replaceSchemaRows: jest.fn(() => ({ rows: [{ name: 'Status' }], errors: [] })), + }; + const createDataPopulationPanelComponentFn = jest.fn(({ callbacks }) => { + panel.callbacks = callbacks; + return panel; + }); + + const control = createTestDataGenerationPanelManager({ + documentObj: document, + initializeSchemaErrorDisplayFn: jest.fn(), + identifyFakerCommandsFn: jest.fn(), + createTestDataGenerationServiceFn: jest.fn(() => ({ + updatePairwiseButtonVisibility: jest.fn(), + generateTestData: jest.fn(), + generatePairwiseTestData: jest.fn(), + })), + createDataPopulationPanelComponentFn, + createTestDataUiStatusServiceFn: jest.fn(() => ({ + setTestDataStatus: jest.fn(), + setTestDataLoadingStatus: jest.fn(), + clearPendingTestDataStatusReset: jest.fn(), + scheduleTestDataStatusReset: jest.fn(), + destroy: jest.fn(), + })), + createCombinationsDialogComponentFn: jest.fn(() => ({ destroy: jest.fn() })), + }); + + control.mountTestDataGenerationPanel( + 'host', + {}, + {}, + { + getGridAsGenericDataTable: jest.fn(() => ({ + getHeaders: () => ['Status', 'Priority'], + getRowCount: () => 4, + getCell: (rowIndex, columnIndex) => + [ + ['active', 'high'], + ['pending', 'low'], + ['active', 'low'], + ['inactive', 'high'], + ][rowIndex][columnIndex], + })), + } + ); + + const controlWithDialogs = createTestDataGenerationPanelManager({ + documentObj: document, + initializeSchemaErrorDisplayFn: jest.fn(), + identifyFakerCommandsFn: jest.fn(), + createTestDataGenerationServiceFn: jest.fn(() => ({ + updatePairwiseButtonVisibility: jest.fn(), + generateTestData: jest.fn(), + generatePairwiseTestData: jest.fn(), + })), + createDataPopulationPanelComponentFn, + createTestDataUiStatusServiceFn: jest.fn(() => ({ + setTestDataStatus: jest.fn(), + setTestDataLoadingStatus: jest.fn(), + clearPendingTestDataStatusReset: jest.fn(), + scheduleTestDataStatusReset: jest.fn(), + destroy: jest.fn(), + })), + createCombinationsDialogComponentFn: jest.fn(() => ({ destroy: jest.fn() })), + createTextInputDialogServiceFn: jest.fn(() => textInputDialogService), + createConfirmDialogServiceFn: jest.fn(() => confirmDialogService), + }); + + controlWithDialogs.mountTestDataGenerationPanel( + 'host', + {}, + {}, + { + getGridAsGenericDataTable: jest.fn(() => ({ + getHeaders: () => ['Status', 'Priority'], + getRowCount: () => 4, + getCell: (rowIndex, columnIndex) => + [ + ['active', 'high'], + ['pending', 'low'], + ['active', 'low'], + ['inactive', 'high'], + ][rowIndex][columnIndex], + })), + } + ); + + await panel.callbacks.onGenerateSchemaFromGrid(); + + expect(requestTextInput).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Grid to Enum Schema', + message: '- largest Column has 3 unique values', + label: 'Limit imported enum(s) to max size of', + initialValue: '3', + inputType: 'number', + }) + ); + expect(confirmDialogService.requestConfirm).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Truncate to first 2 values'), + }) + ); + expect(panel.replaceSchemaRows).toHaveBeenCalledWith([ + expect.objectContaining({ name: 'Status', sourceType: 'enum', value: 'active,pending' }), + expect.objectContaining({ name: 'Priority', sourceType: 'enum', value: 'high,low' }), + ]); + expect(panel.setGenerateSchemaBusy.mock.calls).toEqual([[true], [false]]); + }); + + test('caps the suggested enum limit prompt at 256 when the grid has more unique values', async () => { + const requestTextInput = jest.fn(async () => null); + const textInputDialogService = { + requestTextInput, + destroy: jest.fn(), + }; + const panel = { + destroy: jest.fn(), + getMode: jest.fn(() => 'new-table'), + setPairwiseVisible: jest.fn(), + setRowCountValue: jest.fn(), + setGenerateSchemaBusy: jest.fn(), + validateSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), + syncSchemaTextFromRows: jest.fn(), + insertSampleSchema: jest.fn(), + getSchemaDefinition: jest.fn(() => ({ replaceRows: jest.fn() })), + getState: jest.fn(() => ({ + schemaDefinitionProps: { + createBlankRow: () => ({ + id: 'generated-row', + name: '', + sourceType: 'regex', + command: '', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }), + }, + })), + replaceSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), + }; + const createDataPopulationPanelComponentFn = jest.fn(({ callbacks }) => { + panel.callbacks = callbacks; + return panel; + }); + + const control = createTestDataGenerationPanelManager({ + documentObj: document, + initializeSchemaErrorDisplayFn: jest.fn(), + identifyFakerCommandsFn: jest.fn(), + createTestDataGenerationServiceFn: jest.fn(() => ({ + updatePairwiseButtonVisibility: jest.fn(), + generateTestData: jest.fn(), + generatePairwiseTestData: jest.fn(), + })), + createDataPopulationPanelComponentFn, + createTestDataUiStatusServiceFn: jest.fn(() => ({ + setTestDataStatus: jest.fn(), + setTestDataLoadingStatus: jest.fn(), + clearPendingTestDataStatusReset: jest.fn(), + scheduleTestDataStatusReset: jest.fn(), + destroy: jest.fn(), + })), + createCombinationsDialogComponentFn: jest.fn(() => ({ destroy: jest.fn() })), + createTextInputDialogServiceFn: jest.fn(() => textInputDialogService), + }); + + control.mountTestDataGenerationPanel( + 'host', + {}, + {}, + { + getGridAsGenericDataTable: jest.fn(() => ({ + getHeaders: () => ['Status'], + getRowCount: () => 300, + getCell: (rowIndex) => `value-${rowIndex}`, + })), + } + ); + + await panel.callbacks.onGenerateSchemaFromGrid(); + + expect(requestTextInput).toHaveBeenCalledWith( + expect.objectContaining({ + message: '- largest Column has 300 unique values', + initialValue: '256', + inputType: 'number', + }) + ); + expect(panel.replaceSchemaRows).not.toHaveBeenCalled(); + }); + + test('resets the schema busy flag when loading status throws during grid-to-enum startup', async () => { + const panel = { + destroy: jest.fn(), + getMode: jest.fn(() => 'new-table'), + setPairwiseVisible: jest.fn(), + setRowCountValue: jest.fn(), + setGenerateSchemaBusy: jest.fn(), + validateSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), + syncSchemaTextFromRows: jest.fn(), + insertSampleSchema: jest.fn(), + getSchemaDefinition: jest.fn(() => ({ replaceRows: jest.fn() })), + getState: jest.fn(() => ({ + schemaDefinitionProps: { + createBlankRow: () => ({ + id: 'generated-row', + name: '', + sourceType: 'regex', + command: '', + params: '', + value: '', + comments: '', + leadingTextLines: [], + }), + }, + })), + replaceSchemaRows: jest.fn(() => ({ rows: [], errors: [] })), + }; + const createDataPopulationPanelComponentFn = jest.fn(({ callbacks }) => { + panel.callbacks = callbacks; + return panel; + }); + const loadingError = new Error('status torn down'); + + const control = createTestDataGenerationPanelManager({ + documentObj: document, + initializeSchemaErrorDisplayFn: jest.fn(), + identifyFakerCommandsFn: jest.fn(), + createTestDataGenerationServiceFn: jest.fn(() => ({ + updatePairwiseButtonVisibility: jest.fn(), + generateTestData: jest.fn(), + generatePairwiseTestData: jest.fn(), + })), + createDataPopulationPanelComponentFn, + createTestDataUiStatusServiceFn: jest.fn(() => ({ + setTestDataStatus: jest.fn(), + setTestDataLoadingStatus: jest.fn(() => { + throw loadingError; + }), + clearPendingTestDataStatusReset: jest.fn(), + scheduleTestDataStatusReset: jest.fn(), + destroy: jest.fn(), + })), + createCombinationsDialogComponentFn: jest.fn(() => ({ destroy: jest.fn() })), + }); + + control.mountTestDataGenerationPanel( + 'host', + {}, + {}, + { + getGridAsGenericDataTable: jest.fn(() => ({ + getHeaders: () => ['Status'], + getRowCount: () => 1, + getCell: () => 'active', + })), + } + ); + + await expect(panel.callbacks.onGenerateSchemaFromGrid()).rejects.toThrow(loadingError); + expect(panel.setGenerateSchemaBusy.mock.calls).toEqual([[true], [false]]); + }); }); diff --git a/packages/core-ui/src/tests/grid/schema/grid-to-enum-schema.test.js b/packages/core-ui/src/tests/grid/schema/grid-to-enum-schema.test.js new file mode 100644 index 00000000..4a5ca3b6 --- /dev/null +++ b/packages/core-ui/src/tests/grid/schema/grid-to-enum-schema.test.js @@ -0,0 +1,97 @@ +import { GenericDataTable } from '../../../../../core/js/data_formats/generic-data-table.js'; +import { + DEFAULT_ENUM_LIMIT, + buildGridEnumSchemaSummary, + createEnumSchemaRowsFromGrid, + normaliseEnumLimit, +} from '../../../../js/gui_components/app/test-data-grid/schema/grid-to-enum-schema.js'; + +function createTable({ headers = [], rows = [] } = {}) { + const table = new GenericDataTable(); + table.setHeaders(headers); + rows.forEach((row) => { + table.rows.push(row.map((cell) => String(cell ?? ''))); + }); + return table; +} + +describe('grid-to-enum-schema', () => { + test('uses a positive integer limit and falls back to the default for invalid inputs', () => { + expect(normaliseEnumLimit(10)).toBe(10); + expect(normaliseEnumLimit('7')).toBe(7); + expect(normaliseEnumLimit('0')).toBe(DEFAULT_ENUM_LIMIT); + expect(normaliseEnumLimit('-2')).toBe(DEFAULT_ENUM_LIMIT); + expect(normaliseEnumLimit('abc')).toBe(DEFAULT_ENUM_LIMIT); + }); + + test('preserves first-seen value order and reports max unique counts', () => { + const table = createTable({ + headers: ['Status', 'Priority'], + rows: [ + ['active', 'high'], + ['pending', 'low'], + ['active', 'high'], + ['inactive', 'low'], + ], + }); + + const summary = buildGridEnumSchemaSummary({ dataTable: table, maxEnumValues: 10 }); + + expect(summary.maxUniqueValueCount).toBe(3); + expect(summary.truncatedColumnCount).toBe(0); + expect(summary.usableColumns.map((column) => column.header)).toEqual(['Status', 'Priority']); + expect(summary.usableColumns[0].uniqueValues).toEqual(['active', 'pending', 'inactive']); + expect(summary.usableColumns[1].uniqueValues).toEqual(['high', 'low']); + }); + + test('skips blank values and truncates to the requested limit', () => { + const table = createTable({ + headers: ['Status', 'Empty'], + rows: [ + ['active', ''], + [' pending ', ' '], + ['', ''], + ['inactive', ''], + ], + }); + + const result = createEnumSchemaRowsFromGrid({ + dataTable: table, + maxEnumValues: 2, + createBlankRow: () => ({ id: 'row', sourceType: 'regex', value: 'seed' }), + }); + + expect(result.maxUniqueValueCount).toBe(3); + expect(result.truncatedColumnCount).toBe(1); + expect(result.rows).toEqual([ + expect.objectContaining({ + name: 'Status', + sourceType: 'enum', + value: 'active,pending', + command: '', + params: '', + comments: '', + }), + ]); + }); + + test('quotes enum values that contain commas so they stay single choices', () => { + const table = createTable({ + headers: ['City'], + rows: [['New York, NY'], ['London'], ['New York, NY']], + }); + + const result = createEnumSchemaRowsFromGrid({ + dataTable: table, + createBlankRow: () => ({ id: 'row', sourceType: 'regex', value: 'seed' }), + }); + + expect(result.rows).toEqual([ + expect.objectContaining({ + name: 'City', + sourceType: 'enum', + value: '"New York, NY",London', + }), + ]); + }); +}); diff --git a/packages/core-ui/src/tests/shared/dialog-services.test.js b/packages/core-ui/src/tests/shared/dialog-services.test.js index e2ee50f9..768780d9 100644 --- a/packages/core-ui/src/tests/shared/dialog-services.test.js +++ b/packages/core-ui/src/tests/shared/dialog-services.test.js @@ -105,14 +105,26 @@ describe('dialog services', () => { const resultPromise = service.requestTextInput({ title: 'Filter Column', - initialValue: 'Old', + message: 'Only numeric values are allowed', + label: 'Maximum size', + initialValue: '3', + inputType: 'number', + min: 1, + step: 1, }); const input = dom.window.document.getElementById('text-input-modal-field'); - input.value = 'Updated'; + expect(dom.window.document.querySelector('[data-role="text-input-dialog-message"]').textContent).toBe( + 'Only numeric values are allowed' + ); + expect(dom.window.document.querySelector('[data-role="text-input-dialog-label"]').textContent).toBe('Maximum size'); + expect(input.getAttribute('type')).toBe('number'); + expect(input.getAttribute('min')).toBe('1'); + expect(input.getAttribute('step')).toBe('1'); + input.value = '42'; dom.window.document.getElementById('text-input-modal-ok').click(); - await expect(resultPromise).resolves.toBe('Updated'); + await expect(resultPromise).resolves.toBe('42'); }); test('text input dialog service resolves null on cancel, backdrop dismiss, and Escape', async () => { 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 31767137..1d1be8e0 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 @@ -264,4 +264,28 @@ describe('shared-schema-definition view', () => { expect(reboundBinding).toBeTruthy(); expect(reboundBinding.options.content(helpIcon)).toContain('Edit as Schema'); }); + + test('replaceRows rerenders and refreshes text mode output', () => { + const component = createComponent(); + + fireEvent.click(document.querySelector('[data-role="schema-mode-toggle"]')); + + const result = component.replaceRows([ + { + id: 'replacement-row', + name: 'Status', + sourceType: 'enum', + command: '', + params: '', + value: 'active,inactive', + comments: '', + leadingTextLines: [], + }, + ]); + + expect(result.errors).toEqual([]); + expect(component.getState().rows).toHaveLength(1); + expect(document.querySelector('[data-role="schema-textbox"]').value).toContain('Status\nenum(active,inactive)'); + expect(document.querySelectorAll('.shared-schema-row')).toHaveLength(1); + }); }); diff --git a/packages/core-ui/src/tests/utils/modal-text-input.test.js b/packages/core-ui/src/tests/utils/modal-text-input.test.js index a4b0bba9..e34c8f5c 100644 --- a/packages/core-ui/src/tests/utils/modal-text-input.test.js +++ b/packages/core-ui/src/tests/utils/modal-text-input.test.js @@ -100,4 +100,35 @@ describe('createTextInputDialogComponent', () => { document.querySelector('[data-role="text-input-dialog-cancel"]').click(); await expect(promise).resolves.toBeNull(); }); + + test('renders optional message, label, and numeric field configuration', async () => { + const component = createTextInputDialogComponent({ + documentObj: document, + }); + + const promise = component.requestTextInput({ + title: 'Grid to Enum Schema', + message: '- largest Column has 9 unique values', + label: 'Limit imported enum(s) to max size of', + initialValue: '9', + inputType: 'number', + min: 1, + step: 1, + }); + + const message = document.querySelector('[data-role="text-input-dialog-message"]'); + const label = document.querySelector('[data-role="text-input-dialog-label"]'); + const input = document.querySelector('[data-role="text-input-dialog-field"]'); + + expect(message.textContent).toBe('- largest Column has 9 unique values'); + expect(message.hidden).toBe(false); + expect(label.textContent).toBe('Limit imported enum(s) to max size of'); + expect(label.hidden).toBe(false); + expect(input.getAttribute('type')).toBe('number'); + expect(input.getAttribute('min')).toBe('1'); + expect(input.getAttribute('step')).toBe('1'); + + document.querySelector('[data-role="text-input-dialog-cancel"]').click(); + await expect(promise).resolves.toBeNull(); + }); }); diff --git a/packages/core/js/data_generation/utils/enumParser.js b/packages/core/js/data_generation/utils/enumParser.js index 5ed76b1e..53757d55 100644 --- a/packages/core/js/data_generation/utils/enumParser.js +++ b/packages/core/js/data_generation/utils/enumParser.js @@ -3,6 +3,10 @@ * Handles both simple comma-separated and function-based enum formats */ export class EnumParser { + static unescapeQuotedEnumValue(value) { + return String(value ?? '').replace(/\\(["\\])/g, '$1'); + } + static isShorthandEnumFormat(ruleSpec) { return /^enum\s+/.test(String(ruleSpec || '').trim()); } @@ -51,7 +55,7 @@ export class EnumParser { .map((v) => { const trimmed = v.trim(); if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { - return trimmed.slice(1, -1); + return this.unescapeQuotedEnumValue(trimmed.slice(1, -1)); } return trimmed; }); @@ -108,7 +112,7 @@ export class EnumParser { return values.map((value) => { const trimmed = value.trim(); if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { - return trimmed.slice(1, -1); + return this.unescapeQuotedEnumValue(trimmed.slice(1, -1)); } return trimmed; });