From 00bec7916bc99e5937d2a55723040541495a205e Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:32:57 +0200 Subject: [PATCH] Fix zero bounds and validate numeric coding generation --- .../generate-coding-dialog.component.spec.ts | 5 +- .../generate-coding-dialog.component.ts | 214 ++++++------------ .../generate-coding-dialog.numeric.spec.ts | 203 +++++++++++++++++ .../translations/de.json | 2 + 4 files changed, 282 insertions(+), 142 deletions(-) create mode 100644 projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.numeric.spec.ts diff --git a/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.component.spec.ts b/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.component.spec.ts index 833db8d..55b059f 100644 --- a/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.component.spec.ts +++ b/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.component.spec.ts @@ -143,7 +143,7 @@ describe('GenerateCodingDialogComponent', () => { expect(component.numericRuleText).toContain('coding.generate.only-one-upper-limit'); }); - it('updateNumericRuleText should build full range text when moreThen < max', () => { + it('updateNumericRuleText should preserve an exclusive lower limit when moreThen < max', () => { const { component } = createComponent({ type: 'integer', multiple: false @@ -155,7 +155,8 @@ describe('GenerateCodingDialogComponent', () => { component.updateNumericRuleText(); expect(component.numericRuleError).toBeFalse(); - expect(component.numericRuleText).toContain('rule.NUMERIC_FULL_RANGE'); + expect(component.numericRuleText).toContain('rule.NUMERIC_MORE_THAN'); + expect(component.numericRuleText).toContain('rule.NUMERIC_MAX'); expect(component.numericRuleText).toContain('1'); expect(component.numericRuleText).toContain('10'); }); diff --git a/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.component.ts b/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.component.ts index 98299ec..f6c6590 100644 --- a/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.component.ts +++ b/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.component.ts @@ -70,6 +70,11 @@ interface OptionData { label?: string; } +interface NumericCodingDefinition { + rules: CodingRule[]; + errorKeys: string[]; +} + @Component({ templateUrl: 'generate-coding-dialog.component.html', styles: [ @@ -317,84 +322,71 @@ export class GenerateCodingDialogComponent { } updateNumericRuleText() { - this.numericRuleError = false; - const matchValue = CodingFactory.getValueAsNumber(this.numericMatch); - // Only show NUMERIC_MATCH text if numericMatch is not empty - if (matchValue && this.numericMatch !== '') { - this.numericRuleText = `${this.translateService.instant( - 'rule.NUMERIC_MATCH' - )}: ${matchValue}`; - } else { - const moreThenValue = CodingFactory.getValueAsNumber( - this.numericMoreThen - ); - const maxValue = CodingFactory.getValueAsNumber(this.numericMax); - const minValue = CodingFactory.getValueAsNumber(this.numericMin); - const lessThenValue = CodingFactory.getValueAsNumber( - this.numericLessThen - ); - this.numericRuleText = ''; - if (moreThenValue && minValue) { - this.numericRuleText = this.translateService.instant( - 'coding.generate.only-one-lower-limit' - ); - this.numericRuleError = true; - } - if (lessThenValue && maxValue) { - this.numericRuleText += ` ${this.translateService.instant( - 'coding.generate.only-one-upper-limit' - )}`; - this.numericRuleError = true; - } - if (!this.numericRuleText) { - const ruleTexts: string[] = []; - const hasFullRange = - moreThenValue && maxValue && moreThenValue < maxValue; - const hasClosedRange = - minValue && maxValue && minValue <= maxValue; - - if (hasFullRange) { - this.numericRuleText = `${this.translateService.instant( - 'rule.NUMERIC_FULL_RANGE' - )} ${moreThenValue} / ${maxValue}`; - } else if (hasClosedRange) { - this.numericRuleText = `${this.translateService.instant( - 'rule.NUMERIC_FULL_RANGE' - )} ${minValue} / ${maxValue}`; - } else { - if (moreThenValue) { - ruleTexts.push( - `${this.translateService.instant( - 'rule.NUMERIC_MORE_THAN' - )} ${moreThenValue}` - ); - } - if (minValue) { - ruleTexts.push( - `${this.translateService.instant('rule.NUMERIC_MIN')} ${minValue}` - ); - } - if (lessThenValue) { - ruleTexts.push( - `${this.translateService.instant( - 'rule.NUMERIC_LESS_THAN' - )} ${lessThenValue}` - ); - } - if (maxValue) { - ruleTexts.push( - `${this.translateService.instant('rule.NUMERIC_MAX')} ${maxValue}` - ); - } + const { rules, errorKeys } = this.getNumericCodingDefinition(); + this.numericRuleError = errorKeys.length > 0; + this.numericRuleText = this.numericRuleError ? + errorKeys.map(key => this.translateService.instant(key)).join(' ') : + rules.map(rule => { + const label = this.translateService.instant(`rule.${rule.method}`); + const separator = rule.method === 'NUMERIC_MATCH' ? ': ' : ' '; + return `${label}${separator}${rule.parameters?.join(' / ')}`; + }).join('; '); + } - this.numericRuleText = - ruleTexts.length > 0 ? - ruleTexts.join('; ') : - this.translateService.instant('coding.generate.empty-value'); - this.numericRuleError = ruleTexts.length === 0; - } - } + private getNumericCodingDefinition(): NumericCodingDefinition { + const parseValue = (value: string): number | null => { + // The response converter returns 0 for an empty string, so check presence first. + if (value.trim() === '') return null; + const numericValue = CodingFactory.getValueAsNumber(value); + return numericValue !== null && Number.isFinite(numericValue) ? numericValue : null; + }; + const invalid = (errorKeys: string[]): NumericCodingDefinition => ({ rules: [], errorKeys }); + + if (this.numericMatch.trim() !== '') { + const matchValue = parseValue(this.numericMatch); + return matchValue === null ? + invalid(['coding.generate.invalid-number']) : + { rules: [{ method: 'NUMERIC_MATCH', parameters: [matchValue.toString(10)] }], errorKeys: [] }; + } + + const inputs: Array<[CodingRule['method'], string]> = [ + ['NUMERIC_MORE_THAN', this.numericMoreThen], + ['NUMERIC_MIN', this.numericMin], + ['NUMERIC_LESS_THAN', this.numericLessThen], + ['NUMERIC_MAX', this.numericMax] + ]; + const values = inputs.map(([, value]) => parseValue(value)); + if (inputs.some(([, value], index) => value.trim() !== '' && values[index] === null)) { + return invalid(['coding.generate.invalid-number']); + } + + const [moreThan, min, lessThan, max] = values; + const errorKeys: string[] = []; + if (moreThan !== null && min !== null) errorKeys.push('coding.generate.only-one-lower-limit'); + if (lessThan !== null && max !== null) errorKeys.push('coding.generate.only-one-upper-limit'); + if (errorKeys.length > 0) return invalid(errorKeys); + if (values.every(value => value === null)) return invalid(['coding.generate.empty-value']); + + const lower = moreThan ?? min; + const upper = lessThan ?? max; + if (lower !== null && upper !== null && + (lower > upper || (lower === upper && (moreThan !== null || lessThan !== null)))) { + return invalid(['coding.generate.invalid-range']); } + + if (min !== null && max !== null) { + return { + rules: [{ method: 'NUMERIC_FULL_RANGE', parameters: [min.toString(10), max.toString(10)] }], + errorKeys: [] + }; + } + + const rules: CodingRule[] = []; + inputs.forEach(([method], index) => { + const value = values[index]; + if (value !== null) rules.push({ method, parameters: [value.toString(10)] }); + }); + return { rules, errorKeys: [] }; } // eslint-disable-next-line class-methods-use-this @@ -460,6 +452,10 @@ export class GenerateCodingDialogComponent { return this.createGeoGebraPointRules() !== null; } + if (this.isNumericCodingMode()) { + return this.getNumericCodingDefinition().errorKeys.length === 0; + } + return true; } @@ -766,69 +762,7 @@ export class GenerateCodingDialogComponent { this.dialogRef.close(null); } } else if (this.isNumericCodingMode()) { - const numericRules: CodingRule[] = []; - const matchValue = CodingFactory.getValueAsNumber(this.numericMatch); - if (matchValue !== null && this.numericMatch !== '') { - numericRules.push({ - method: 'NUMERIC_MATCH', - parameters: [matchValue.toString(10)] - }); - } else { - const moreThanValue = CodingFactory.getValueAsNumber( - this.numericMoreThen - ); - const maxValue = CodingFactory.getValueAsNumber(this.numericMax); - const minValue = CodingFactory.getValueAsNumber(this.numericMin); - const lessThanValue = CodingFactory.getValueAsNumber( - this.numericLessThen - ); - - const hasRangeOverlap = - moreThanValue && maxValue && moreThanValue < maxValue; - - const hasClosedRange = - minValue && maxValue && minValue <= maxValue; - - if (hasRangeOverlap) { - numericRules.push({ - method: 'NUMERIC_FULL_RANGE', - parameters: [moreThanValue.toString(10), maxValue.toString(10)] - }); - } else if (hasClosedRange) { - numericRules.push({ - method: 'NUMERIC_FULL_RANGE', - parameters: [minValue.toString(10), maxValue.toString(10)] - }); - } else { - if (moreThanValue) { - numericRules.push({ - method: 'NUMERIC_MORE_THAN', - parameters: [moreThanValue.toString(10)] - }); - } - - if (minValue) { - numericRules.push({ - method: 'NUMERIC_MIN', - parameters: [minValue.toString(10)] - }); - } - - if (lessThanValue) { - numericRules.push({ - method: 'NUMERIC_LESS_THAN', - parameters: [lessThanValue.toString(10)] - }); - } - - if (maxValue) { - numericRules.push({ - method: 'NUMERIC_MAX', - parameters: [maxValue.toString(10)] - }); - } - } - } + const numericRules = this.getNumericCodingDefinition().rules; const newCode = this.schemerService.addCode( newVardata.codes || [], 'FULL_CREDIT' @@ -837,7 +771,7 @@ export class GenerateCodingDialogComponent { newCode.ruleSetOperatorAnd = true; newCode.ruleSets = [ { - ruleOperatorAnd: false, + ruleOperatorAnd: true, ...(this.varInfo.multiple ? { valueArrayPos: this.getSelectedArrayPos() } : {}), rules: numericRules } diff --git a/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.numeric.spec.ts b/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.numeric.spec.ts new file mode 100644 index 0000000..7fa97d4 --- /dev/null +++ b/projects/ngx-coding-components/src/lib/var-coding/dialogs/generate-coding-dialog.numeric.spec.ts @@ -0,0 +1,203 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CodingFactory } from '@iqb/responses/coding-factory'; +import { CodingRule, VariableCodingData } from '@iqbspecs/coding-scheme'; +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { NgxCodingComponentsTranslateLoader } from '@iqb/ngx-coding-components/translations'; +import { SchemerService } from '../../services/schemer.service'; +import { GenerateCodingDialogComponent } from './generate-coding-dialog.component'; + +const numericVarInfo: VariableInfo = { + id: 'I06_NUM', + alias: 'I06_NUM', + type: 'integer', + format: '', + multiple: false, + nullable: true, + values: [], + valuesComplete: false, + valuePositionLabels: [] +}; + +type NumericInputs = Partial>; + +const getFullCreditRules = (coding: VariableCodingData): CodingRule[] => ( + coding.codes!.find(code => code.type === 'FULL_CREDIT')!.ruleSets![0].rules +); + +const codeValues = (coding: VariableCodingData, values: number[]): Array => ( + values.map(value => CodingFactory.code({ id: coding.id, value, status: 'VALUE_CHANGED' }, coding).score) +); + +describe('GenerateCodingDialogComponent numeric rules with the real response converter', () => { + const createComponent = (inputs: NumericInputs = {}, varInfo = numericVarInfo) => { + const dialogRef = jasmine.createSpyObj>('MatDialogRef', ['close']); + const translateService = jasmine.createSpyObj('TranslateService', ['instant']); + translateService.instant.and.callFake((key: string) => key); + const schemerService = new SchemerService(); + const component = new GenerateCodingDialogComponent(varInfo, translateService, schemerService, dialogRef); + Object.assign(component, inputs); + component.updateNumericRuleText(); + return { component, dialogRef, schemerService }; + }; + + const zeroLimits: Array<[keyof NumericInputs, CodingRule['method'], number[]]> = [ + ['numericMoreThen', 'NUMERIC_MORE_THAN', [0, 0, 1]], + ['numericMin', 'NUMERIC_MIN', [0, 1, 1]], + ['numericLessThen', 'NUMERIC_LESS_THAN', [1, 0, 0]], + ['numericMax', 'NUMERIC_MAX', [1, 1, 0]], + ['numericMatch', 'NUMERIC_MATCH', [0, 1, 0]] + ]; + zeroLimits.forEach(([field, method, scores]) => { + it(`should preview, generate and evaluate ${field} = 0`, () => { + const { component, dialogRef } = createComponent({ [field]: '0' }); + expect(component.numericRuleError).toBeFalse(); + expect(component.numericRuleText).toContain(`rule.${method}`); + expect(component.numericRuleText).toContain('0'); + expect(component.canGenerate()).toBeTrue(); + + component.generateButtonClick(); + const coding = dialogRef.close.calls.mostRecent().args[0] as VariableCodingData; + expect(getFullCreditRules(coding)).toEqual([{ method, parameters: ['0'] }]); + expect(codeValues(coding, [-1, 0, 1])).toEqual(scores); + }); + }); + + const ranges: Array<[string, NumericInputs, number[], number[]]> = [ + ['[0, 10]', { numericMin: '0', numericMax: '10' }, [-5, 0, 10, 11], [0, 1, 1, 0]], + ['[-10, 0]', { numericMin: '-10', numericMax: '0' }, [-11, -10, 0, 1], [0, 1, 1, 0]], + ['[0, 0]', { numericMin: '0', numericMax: '0' }, [-1, 0, 1], [0, 1, 0]], + ['(0, 10]', { numericMoreThen: '0', numericMax: '10' }, [-5, 0, 1, 10, 11], [0, 0, 1, 1, 0]], + ['[0, 10)', { numericMin: '0', numericLessThen: '10' }, [-5, 0, 1, 10, 11], [0, 1, 1, 0, 0]], + ['(0, 10)', { numericMoreThen: '0', numericLessThen: '10' }, [-5, 0, 1, 10, 11], [0, 0, 1, 0, 0]] + ]; + ranges.forEach(([label, inputs, values, scores]) => { + it(`should evaluate both boundaries of ${label}`, () => { + const { component, dialogRef } = createComponent(inputs); + expect(component.canGenerate()).toBeTrue(); + expect(component.numericRuleError).toBeFalse(); + component.generateButtonClick(); + const coding = dialogRef.close.calls.mostRecent().args[0] as VariableCodingData; + expect(coding.codes!.find(code => code.type === 'FULL_CREDIT')!.ruleSets![0].ruleOperatorAnd).toBeTrue(); + expect(codeValues(coding, values)).toEqual(scores); + }); + }); + + it('should ignore absent limits rather than converting them to zero', () => { + const { component, dialogRef } = createComponent({ numericMin: ' ', numericMax: '10' }); + component.generateButtonClick(); + const coding = dialogRef.close.calls.mostRecent().args[0] as VariableCodingData; + expect(getFullCreditRules(coding)).toEqual([{ method: 'NUMERIC_MAX', parameters: ['10'] }]); + expect(codeValues(coding, [-5, 10, 11])).toEqual([1, 1, 0]); + }); + + it('should preserve match precedence and accept decimal commas', () => { + const { component, dialogRef } = createComponent({ numericMatch: ' 0,5 ', numericMin: '10' }); + component.generateButtonClick(); + const coding = dialogRef.close.calls.mostRecent().args[0] as VariableCodingData; + expect(getFullCreditRules(coding)).toEqual([{ method: 'NUMERIC_MATCH', parameters: ['0.5'] }]); + expect(codeValues(coding, [0, 0.5, 10])).toEqual([0, 1, 0]); + }); + + const invalidInputs: Array<[string, NumericInputs, string]> = [ + ['empty definition', {}, 'empty-value'], + ['whitespace', { numericMatch: ' ', numericMin: ' ' }, 'empty-value'], + ['invalid match with valid bounds', { numericMatch: 'abc', numericMax: '10' }, 'invalid-number'], + ['invalid lower limit', { numericMin: 'abc', numericMax: '10' }, 'invalid-number'], + ['invalid upper limit', { numericMin: '0', numericMax: 'abc' }, 'invalid-number'], + ['infinity', { numericMatch: 'Infinity' }, 'invalid-number'], + ['overflow', { numericMatch: '9'.repeat(400) }, 'invalid-number'], + ['conflicting lower limits with zero', { numericMoreThen: '0', numericMin: '1' }, 'only-one-lower-limit'], + ['conflicting upper limits with zero', { numericLessThen: '0', numericMax: '1' }, 'only-one-upper-limit'], + ['reversed bounds', { numericMin: '10', numericMax: '0' }, 'invalid-range'], + ['empty strict lower range', { numericMoreThen: '0', numericMax: '0' }, 'invalid-range'], + ['empty strict upper range', { numericMin: '0', numericLessThen: '0' }, 'invalid-range'] + ]; + invalidInputs.forEach(([label, inputs, errorKey]) => { + it(`should reject ${label} in preview, button state and generation`, () => { + const { component, dialogRef, schemerService } = createComponent(inputs); + const addCode = spyOn(schemerService, 'addCode').and.callThrough(); + expect(component.numericRuleError).toBeTrue(); + expect(component.numericRuleText).toContain(`coding.generate.${errorKey}`); + expect(component.canGenerate()).toBeFalse(); + component.generateButtonClick(); + expect(dialogRef.close).toHaveBeenCalledWith(null); + expect(addCode).not.toHaveBeenCalled(); + }); + }); + + it('should validate text inputs only when numeric coding is selected', () => { + const { component } = createComponent({}, { ...numericVarInfo, type: 'string' }); + expect(component.canGenerate()).toBeTrue(); + component.textAsNumeric = true; + expect(component.canGenerate()).toBeFalse(); + component.numericMatch = '0'; + expect(component.canGenerate()).toBeTrue(); + }); +}); + +describe('GenerateCodingDialogComponent numeric dialog UI', () => { + let fixture: ComponentFixture; + let dialogRef: jasmine.SpyObj>; + + beforeEach(async () => { + dialogRef = jasmine.createSpyObj>('MatDialogRef', ['close']); + await TestBed.configureTestingModule({ + imports: [GenerateCodingDialogComponent, NoopAnimationsModule, TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: NgxCodingComponentsTranslateLoader } + })], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: numericVarInfo }, + { provide: MatDialogRef, useValue: dialogRef } + ] + }).compileComponents(); + TestBed.inject(TranslateService).use('de'); + fixture = TestBed.createComponent(GenerateCodingDialogComponent); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }); + + const getGenerateButton = (): HTMLButtonElement => ( + fixture.nativeElement.querySelector('button[color="primary"]') + ); + const enterValue = async (label: string, value: string): Promise => { + const fields = Array.from(fixture.nativeElement.querySelectorAll('mat-form-field')); + const field = fields.find(element => element.querySelector('mat-label')?.textContent?.trim() === label)!; + const input = field.querySelector('input')!; + input.value = value; + input.dispatchEvent(new Event('input')); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + }; + + it('should generate and evaluate [0, 10] from the rendered form', async () => { + expect(getGenerateButton().disabled).toBeTrue(); + await enterValue('Größer gleich', '0'); + await enterValue('Kleiner gleich', '10'); + expect(getGenerateButton().disabled).toBeFalse(); + expect(fixture.nativeElement.querySelector('.rule-text-ok').textContent.trim()).toBe('Bereich 0 / 10'); + getGenerateButton().click(); + const coding = dialogRef.close.calls.mostRecent().args[0] as VariableCodingData; + expect(getFullCreditRules(coding)).toEqual([{ method: 'NUMERIC_FULL_RANGE', parameters: ['0', '10'] }]); + expect(codeValues(coding, [-5, 0, 10, 11])).toEqual([0, 1, 1, 0]); + }); + + it('should preview a zero match and disable generation after invalid or cleared input', async () => { + await enterValue('Übereinstimmung', '0'); + expect(getGenerateButton().disabled).toBeFalse(); + expect(fixture.nativeElement.querySelector('.rule-text-ok').textContent.trim()).toBe('Übereinstimmung: 0'); + await enterValue('Übereinstimmung', 'abc'); + expect(getGenerateButton().disabled).toBeTrue(); + expect(fixture.nativeElement.querySelector('.rule-text-error').textContent.trim()) + .toBe('Bitte eine gültige Zahl eintragen'); + await enterValue('Übereinstimmung', ''); + expect(getGenerateButton().disabled).toBeTrue(); + expect(fixture.nativeElement.querySelector('.rule-text-error').textContent.trim()).toBe('Bitte Wert eintragen'); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/ngx-coding-components/translations/de.json b/projects/ngx-coding-components/translations/de.json index 2933114..6428339 100644 --- a/projects/ngx-coding-components/translations/de.json +++ b/projects/ngx-coding-components/translations/de.json @@ -205,6 +205,8 @@ "warning": "Achtung: Sollten Kodierinformationen bereits vorhanden sein, werden diese gelöscht.", "true-code": "Wert für 'Richtig'-Code", "empty-value": "Bitte Wert eintragen", + "invalid-number": "Bitte eine gültige Zahl eintragen", + "invalid-range": "Bitte einen gültigen Bereich eintragen", "only-one-lower-limit": "Bitte nur einen Wert für untere Grenze eintragen", "only-one-upper-limit": "Bitte nur einen Wert für obere Grenze eintragen", "action": "Generiere Kodierung"