diff --git a/projects/ngx-coding-components/src/lib/translations/de.json b/projects/ngx-coding-components/src/lib/translations/de.json index 47c6963..6eb5ab7 100644 --- a/projects/ngx-coding-components/src/lib/translations/de.json +++ b/projects/ngx-coding-components/src/lib/translations/de.json @@ -252,7 +252,18 @@ "TAKE_NOT_REACHED_AS_VALUE_CHANGED": "Nicht erreicht = Wert geändert", "TAKE_EMPTY_AS_VALID": "Leerer Antwortwert ist gültig", "SORT": "Sortiere Werte", - "SOLVER_EXPRESSION": "Ausdruck für Solver" + "SOLVER_EXPRESSION": "Ausdruck für Solver", + "solver-test": { + "title": "Solver-Test", + "action": "Testen", + "result": "Ergebnis", + "no-sources": "Keine Quellvariable ausgewählt.", + "error-expression-missing": "Bitte einen Solver-Ausdruck eingeben.", + "error-sources-missing": "Bitte mindestens eine Quellvariable auswählen.", + "error-unselected-source": "Der Ausdruck verweist auf nicht ausgewählte Quelle(n)", + "error-invalid-value": "Bitte numerischen Testwert prüfen", + "error-evaluation": "Der Ausdruck konnte nicht ausgewertet werden. Bitte Syntax, Variablennamen und numerische Testwerte prüfen." + } }, "processing": { "label": "Allgemeine Parameter für die Verarbeitung der Antwort", diff --git a/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.html b/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.html index 22b5a2f..98ac826 100644 --- a/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.html +++ b/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.html @@ -46,8 +46,45 @@

{{ (newVariableMode ? 'varList.add' : 'derive-processing.pr @if (data.sourceType === 'SOLVER') { {{ 'derive-processing.SOLVER_EXPRESSION' | translate }} - + + +
+
+
{{ 'derive-processing.solver-test.title' | translate }}
+ +
+ + @if (selectedSolverSources.length > 0) { +
+ @for (source of selectedSolverSources; track source.id) { + + {{ source.label }} + + + } +
+ } @else { +
+ {{ 'derive-processing.solver-test.no-sources' | translate }} +
+ } + + @if (solverTestResult) { +
+ {{ solverTestResult.message }} +
+ } +
} diff --git a/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.spec.ts b/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.spec.ts index b0ca687..4d8c8ad 100644 --- a/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.spec.ts +++ b/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.spec.ts @@ -1,9 +1,21 @@ import { SourceType, VariableSourceParameters, SourceProcessingType } from '@iqbspecs/coding-scheme/coding-scheme.interface'; +import { TranslateService } from '@ngx-translate/core'; import { EditSourceParametersDialog, EditSourceParametersDialogData } from './edit-source-parameters-dialog.component'; import { SchemerService } from '../../services/schemer.service'; describe('EditSourceParametersDialog', () => { + const solverRef = (variableName: string): string => `\${${variableName}}`; + + const translations: Record = { + 'derive-processing.solver-test.result': 'Ergebnis', + 'derive-processing.solver-test.error-expression-missing': 'Bitte einen Solver-Ausdruck eingeben.', + 'derive-processing.solver-test.error-sources-missing': 'Bitte mindestens eine Quellvariable auswählen.', + 'derive-processing.solver-test.error-unselected-source': 'Der Ausdruck verweist auf nicht ausgewählte Quelle(n)', + 'derive-processing.solver-test.error-invalid-value': 'Bitte numerischen Testwert prüfen', + 'derive-processing.solver-test.error-evaluation': 'Der Ausdruck konnte nicht ausgewertet werden.' + }; + const createDialog = (options: { selfId?: string; selfAlias?: string; @@ -21,7 +33,7 @@ describe('EditSourceParametersDialog', () => { } const data: EditSourceParametersDialogData = { - selfId: options.selfId ?? 'v1', + selfId: options.selfId ?? 'd1', selfAlias: options.selfAlias ?? 'V1', sourceType: (options.sourceType as SourceType) ?? 'COPY_VALUE', sourceParameters: baseSourceParameters as VariableSourceParameters, @@ -29,19 +41,34 @@ describe('EditSourceParametersDialog', () => { }; const hasCodingSchemeOverride = Object.prototype.hasOwnProperty.call(options, 'codingScheme'); + const codingScheme = hasCodingSchemeOverride ? + options.codingScheme : + { + variableCodings: [ + { id: 'v1', alias: 'V1', sourceType: 'BASE' }, + { id: 'v2', alias: 'V2', sourceType: 'DERIVE' }, + { id: 'v3', alias: 'V3', sourceType: 'BASE_NO_VALUE' } + ] + }; const schemerService = { - codingScheme: hasCodingSchemeOverride ? - options.codingScheme : - { - variableCodings: [ - { id: 'v1', alias: 'V1', sourceType: 'BASE' }, - { id: 'v2', alias: 'V2', sourceType: 'DERIVE' }, - { id: 'v3', alias: 'V3', sourceType: 'BASE_NO_VALUE' } - ] - } + codingScheme, + getVariableAliasById: (varId: string) => { + const variableCoding = codingScheme?.variableCodings.find( + variable => (variable as { id: string }).id === varId + ) as { id: string; alias?: string } | undefined; + return variableCoding?.alias || variableCoding?.id || '?'; + } } as unknown as SchemerService; - return new EditSourceParametersDialog(data, schemerService); + const translateService = { + instant: (key: string) => translations[key] || key + } as unknown as TranslateService; + + return new EditSourceParametersDialog( + data, + schemerService, + translateService + ); }; it('should set newVariableMode when selfId is missing', () => { @@ -117,5 +144,98 @@ describe('EditSourceParametersDialog', () => { dialog.toggleAllSelection(); expect(dialog.selectedSources.value).toEqual(['v2']); + expect(dialog.data.deriveSources).toEqual(['v2']); + }); + + it('runSolverTest should evaluate the current expression with test values', () => { + const dialog = createDialog({ + selfAlias: 'D', + sourceType: 'SOLVER', + sourceParameters: { + solverExpression: `${solverRef('V1')} + ${solverRef('V2')}` + }, + deriveSources: ['v1', 'v2'] + }); + + dialog.solverTestValues['v1'] = '2'; + dialog.solverTestValues['v2'] = '3'; + dialog.runSolverTest(); + + expect(dialog.solverTestResult).toEqual({ + type: 'success', + message: 'Ergebnis: 5' + }); + }); + + it('runSolverTest should show missing selected sources for referenced variables', () => { + const dialog = createDialog({ + selfAlias: 'D', + sourceType: 'SOLVER', + sourceParameters: { + solverExpression: `${solverRef('V1')} + ${solverRef('V2')}` + }, + deriveSources: ['v1'] + }); + + dialog.solverTestValues['v1'] = '2'; + dialog.runSolverTest(); + + expect(dialog.solverTestResult?.type).toBe('error'); + expect(dialog.solverTestResult?.message).toContain( + 'Der Ausdruck verweist auf nicht ausgewählte Quelle(n)' + ); + expect(dialog.solverTestResult?.message).toContain('V2'); + }); + + it('runSolverTest should report non-numeric test values', () => { + const dialog = createDialog({ + selfAlias: 'D', + sourceType: 'SOLVER', + sourceParameters: { solverExpression: `${solverRef('V1')} + 1` }, + deriveSources: ['v1'] + }); + + dialog.solverTestValues['v1'] = 'abc'; + dialog.runSolverTest(); + + expect(dialog.solverTestResult?.type).toBe('error'); + expect(dialog.solverTestResult?.message).toContain( + 'Bitte numerischen Testwert prüfen' + ); + expect(dialog.solverTestResult?.message).toContain('V1'); + }); + + it('runSolverTest should report syntax and evaluation errors', () => { + const dialog = createDialog({ + selfAlias: 'D', + sourceType: 'SOLVER', + sourceParameters: { solverExpression: `${solverRef('V1')} +` }, + deriveSources: ['v1'] + }); + + dialog.solverTestValues['v1'] = '2'; + dialog.runSolverTest(); + + expect(dialog.solverTestResult).toEqual({ + type: 'error', + message: 'Der Ausdruck konnte nicht ausgewertet werden.' + }); + }); + + it('updateDeriveSources should keep solver test values aligned with selected sources', () => { + const dialog = createDialog({ + selfAlias: 'D', + sourceType: 'SOLVER', + deriveSources: ['v1'] + }); + + dialog.solverTestValues['v1'] = '4'; + dialog.solverTestResult = { type: 'success', message: 'Ergebnis: 4' }; + dialog.selectedSources.setValue(['v2']); + dialog.updateDeriveSources(); + + expect(dialog.data.deriveSources).toEqual(['v2']); + expect(dialog.solverTestValues).toEqual({ v2: '' }); + expect(dialog.solverTestResult).toBeNull(); }); }); diff --git a/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.ts b/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.ts index 3804253..d1ddd30 100644 --- a/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.ts +++ b/projects/ngx-coding-components/src/lib/var-coding/dialogs/edit-source-parameters-dialog.component.ts @@ -6,8 +6,9 @@ import { MatDialogActions, MatDialogClose } from '@angular/material/dialog'; -import { TranslateModule } from '@ngx-translate/core'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { MatButton } from '@angular/material/button'; +import { MatIcon } from '@angular/material/icon'; import { MatFormField, MatLabel } from '@angular/material/form-field'; import { MatInput } from '@angular/material/input'; import { MatCheckbox } from '@angular/material/checkbox'; @@ -26,6 +27,8 @@ import { VariableCodingData, VariableSourceParameters } from '@iqbspecs/coding-scheme/coding-scheme.interface'; +import { Response } from '@iqbspecs/response/response.interface'; +import { CodingFactory, CodingSchemeFactory } from '@iqb/responses'; import { SchemerService, VARIABLE_NAME_CHECK_PATTERN @@ -39,6 +42,11 @@ export interface EditSourceParametersDialogData { deriveSources: string[]; } +type SolverTestResult = { + type: 'success' | 'error'; + message: string; +}; + @Component({ templateUrl: 'edit-source-parameters-dialog.component.html', standalone: true, @@ -50,6 +58,7 @@ export interface EditSourceParametersDialogData { MatButton, MatDialogClose, TranslateModule, + MatIcon, FormsModule, MatFormField, MatInput, @@ -61,6 +70,54 @@ export interface EditSourceParametersDialogData { NgForOf, ReactiveFormsModule, NgIf + ], + styles: [ + ` + .solver-test-area { + border-top: 1px solid rgba(0, 0, 0, 0.12); + margin-top: 8px; + padding-top: 16px; + } + + .solver-test-header { + align-items: center; + display: flex; + gap: 16px; + justify-content: space-between; + margin-bottom: 12px; + } + + .solver-test-title { + font-weight: 600; + } + + .solver-test-values { + display: grid; + gap: 8px 12px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + } + + .solver-test-result { + border-radius: 4px; + margin-top: 8px; + padding: 8px 12px; + } + + .solver-test-result-ok { + background: #e8f5e9; + color: #1b5e20; + } + + .solver-test-result-error { + background: #ffebee; + color: #b71c1c; + } + + .solver-test-hint { + color: rgba(0, 0, 0, 0.6); + margin-bottom: 8px; + } + ` ] }) export class EditSourceParametersDialog { @@ -78,9 +135,13 @@ export class EditSourceParametersDialog { selectedSources = new FormControl(); possibleNewSources: ReadonlyMap = new Map([]); newVariableMode = false; + solverTestValues: Record = {}; + solverTestResult: SolverTestResult | null = null; + constructor( @Inject(MAT_DIALOG_DATA) public data: EditSourceParametersDialogData, - public schemerService: SchemerService + public schemerService: SchemerService, + private translateService: TranslateService ) { this.newVariableMode = !this.data.selfId; @@ -101,6 +162,13 @@ export class EditSourceParametersDialog { this.updatePossibleNewSources(); } + get selectedSolverSources(): { id: string; label: string }[] { + return (this.data.deriveSources || []).map(sourceId => ({ + id: sourceId, + label: this.possibleNewSources.get(sourceId) || sourceId + })); + } + updatePossibleNewSources(): void { const codingScheme = this.schemerService.codingScheme; @@ -124,6 +192,7 @@ export class EditSourceParametersDialog { ); this.selectedSources.setValue(this.data.deriveSources); + this.syncSolverTestValues(); } updatePossibleDeriveProcessing(): void { @@ -144,6 +213,7 @@ export class EditSourceParametersDialog { this.possibleDeriveProcessing = processingOptions[this.data.sourceType] || []; + this.clearSolverTestResult(); } alterProcessing(processingId: SourceProcessingType, checked: boolean): void { @@ -163,11 +233,185 @@ export class EditSourceParametersDialog { } updateDeriveSources() { - this.data.deriveSources = this.selectedSources.value; + this.data.deriveSources = this.selectedSources.value || []; + this.syncSolverTestValues(); + this.clearSolverTestResult(); } toggleAllSelection() { this.selectedSources.setValue(Array.from(this.possibleNewSources.keys())); + this.updateDeriveSources(); + } + + clearSolverTestResult(): void { + this.solverTestResult = null; + } + + runSolverTest(): void { + this.updateDeriveSources(); + + const expression = this.data.sourceParameters.solverExpression || ''; + if (!expression.trim()) { + this.setSolverTestError( + 'derive-processing.solver-test.error-expression-missing' + ); + return; + } + + if (!this.data.deriveSources.length) { + this.setSolverTestError( + 'derive-processing.solver-test.error-sources-missing' + ); + return; + } + + const variableCodings = this.getVariableCodingsForSolverTest(); + const referencedVariables = EditSourceParametersDialog.getReferencedSolverVariables( + expression, + variableCodings + ); + const missingSources = referencedVariables.filter( + variableId => !this.data.deriveSources.includes(variableId) + ); + + if (missingSources.length > 0) { + this.solverTestResult = { + type: 'error', + message: `${this.tr( + 'derive-processing.solver-test.error-unselected-source' + )}: ${missingSources.map(sourceId => this.getSourceLabel(sourceId)) + .join(', ')}` + }; + return; + } + + const invalidNumericSources = referencedVariables.filter( + sourceId => CodingFactory.getValueAsNumber( + this.solverTestValues[sourceId] || '' + ) === null + ); + + if (invalidNumericSources.length > 0) { + this.solverTestResult = { + type: 'error', + message: `${this.tr( + 'derive-processing.solver-test.error-invalid-value' + )}: ${invalidNumericSources.map(sourceId => this.getSourceLabel(sourceId)) + .join(', ')}` + }; + return; + } + + try { + const result = CodingSchemeFactory.deriveValue( + variableCodings, + this.getSolverTestCoding(), + this.getSolverTestResponses() + ); + + if (result.status === 'VALUE_CHANGED') { + this.solverTestResult = { + type: 'success', + message: `${this.tr('derive-processing.solver-test.result')}: ${ + EditSourceParametersDialog.formatSolverTestValue(result.value) + }` + }; + return; + } + } catch { + // fall through to the shared error message + } + + this.setSolverTestError( + 'derive-processing.solver-test.error-evaluation' + ); + } + + private syncSolverTestValues(): void { + const selectedSourceIds = new Set(this.data.deriveSources || []); + const nextValues: Record = {}; + + selectedSourceIds.forEach(sourceId => { + nextValues[sourceId] = this.solverTestValues[sourceId] || ''; + }); + + this.solverTestValues = nextValues; + } + + private getVariableCodingsForSolverTest(): VariableCodingData[] { + const sourceCodings = this.schemerService.codingScheme?.variableCodings || + []; + const solverCoding = this.getSolverTestCoding(); + const hasSelfCoding = sourceCodings.some(coding => coding.id === solverCoding.id); + + if (hasSelfCoding) { + return sourceCodings.map( + coding => (coding.id === solverCoding.id ? solverCoding : coding) + ); + } + + return [...sourceCodings, solverCoding]; + } + + private getSolverTestCoding(): VariableCodingData { + return { + id: this.data.selfId || this.data.selfAlias || '__solver_test__', + alias: this.data.selfAlias, + sourceType: 'SOLVER', + sourceParameters: { + ...this.data.sourceParameters, + processing: this.data.sourceParameters.processing || [], + solverExpression: this.data.sourceParameters.solverExpression || '' + }, + deriveSources: [...this.data.deriveSources] + }; + } + + private getSolverTestResponses(): Response[] { + return this.data.deriveSources.map(sourceId => ({ + id: sourceId, + value: this.solverTestValues[sourceId] || '', + status: 'VALUE_CHANGED' + })); + } + + private static getReferencedSolverVariables( + expression: string, + variableCodings: VariableCodingData[] + ): string[] { + const variableIdsByAlias = new Map( + variableCodings + .filter(coding => Boolean(coding.alias)) + .map(coding => [coding.alias as string, coding.id]) + ); + const references = Array.from(expression.matchAll(/\$\{(\s*[\w,-]+\s*)}/g)) + .map(match => match[1].trim()) + .map(variableName => variableIdsByAlias.get(variableName) || + variableName); + + return [...new Set(references)]; + } + + private getSourceLabel(sourceId: string): string { + const sourceAlias = this.schemerService.getVariableAliasById(sourceId); + return this.possibleNewSources.get(sourceId) || + (sourceAlias === '?' ? sourceId : sourceAlias); + } + + private static formatSolverTestValue(value: unknown): string { + if (typeof value === 'string') return value; + return JSON.stringify(value) || ''; + } + + private setSolverTestError(translationKey: string): void { + this.solverTestResult = { + type: 'error', + message: this.tr(translationKey) + }; + } + + private tr(translationKey: string): string { + return this.translateService.instant(translationKey) as string; } // eslint-disable-next-line class-methods-use-this