diff --git a/projects/ngx-coding-components/src/lib/dialogs/select-code-rule-reference-dialog.component.spec.ts b/projects/ngx-coding-components/src/lib/dialogs/select-code-rule-reference-dialog.component.spec.ts index 7916f72..ca56434 100644 --- a/projects/ngx-coding-components/src/lib/dialogs/select-code-rule-reference-dialog.component.spec.ts +++ b/projects/ngx-coding-components/src/lib/dialogs/select-code-rule-reference-dialog.component.spec.ts @@ -1,4 +1,14 @@ -import { MatDialogRef } from '@angular/material/dialog'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatSelectionListHarness } from '@angular/material/list/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { CodingFactory } from '@iqb/responses/coding-factory'; +import { CodingSchemeFactory } from '@iqb/responses'; +import { RuleSet } from '@iqbspecs/coding-scheme/coding-scheme.interface'; +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; +import { NgxCodingComponentsTranslateLoader } from '../translations/ngx-coding-components.translate-loader'; import { SelectCodeRuleReferenceDialogComponent, SelectCodeRuleReferenceDialogData } from './select-code-rule-reference-dialog.component'; @@ -54,3 +64,135 @@ describe('SelectCodeRuleReferenceDialogComponent', () => { expect(dialogRef.close).toHaveBeenCalledWith('ANY_OPEN'); }); }); + +describe('SelectCodeRuleReferenceDialogComponent UI', () => { + let fixture: ComponentFixture; + let dialogRef: jasmine.SpyObj>; + let dialogData: SelectCodeRuleReferenceDialogData; + + beforeEach(async () => { + dialogRef = jasmine.createSpyObj('MatDialogRef', ['close']); + dialogData = { isFragmentMode: false, value: 'ANY' }; + await TestBed.configureTestingModule({ + imports: [SelectCodeRuleReferenceDialogComponent, NoopAnimationsModule, TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: NgxCodingComponentsTranslateLoader } + })], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: dialogData }, + { provide: MatDialogRef, useValue: dialogRef } + ] + }).compileComponents(); + TestBed.inject(TranslateService).use('de'); + }); + + const renderDialog = async () => { + fixture = TestBed.createComponent(SelectCodeRuleReferenceDialogComponent); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + return TestbedHarnessEnvironment.loader(fixture).getHarness(MatSelectionListHarness); + }; + + const getHelp = (): HTMLElement | null => fixture.nativeElement.querySelector('#array-reference-help'); + const save = () => fixture.nativeElement.querySelector('button[color="primary"]').click(); + + it('explains why an additional non-matching value prevents ANY from matching', async () => { + const list = await renderDialog(); + + expect((await list.getItems()).length).toBe(5); + await expectAsync((await list.getItems({ selected: true }))[0].getText()) + .toBeResolvedTo('Alle vorhandenen Werte müssen passen'); + expect(getHelp()?.textContent).toContain('[passend, nicht passend] → kein Treffer'); + expect(fixture.nativeElement.querySelector('mat-selection-list').getAttribute('aria-describedby')) + .toBe('array-reference-help'); + + save(); + expect(dialogRef.close).toHaveBeenCalledWith('ANY'); + }); + + it('updates the explanation and saves ANY_OPEN when at least one value is selected', async () => { + const list = await renderDialog(); + await list.selectItems({ text: 'Mindestens ein Wert muss passen' }); + + expect(getHelp()?.textContent).toContain('Zusätzliche nicht passende Werte sind erlaubt'); + expect(getHelp()?.textContent).toContain('[passend, nicht passend] → Treffer'); + + save(); + expect(dialogRef.close).toHaveBeenCalledWith('ANY_OPEN'); + }); + + it('preserves a loaded ANY_OPEN reference when saving without changes', async () => { + dialogData.value = 'ANY_OPEN'; + const list = await renderDialog(); + + await expectAsync((await list.getItems({ selected: true }))[0].getText()) + .toBeResolvedTo('Mindestens ein Wert muss passen'); + expect(getHelp()?.textContent).toContain('[passend, nicht passend] → Treffer'); + + save(); + expect(dialogRef.close).toHaveBeenCalledWith('ANY_OPEN'); + }); + + it('explains and preserves the empty-array exception for ANY_OPEN with IS_EMPTY', async () => { + dialogData.value = 'ANY_OPEN'; + await renderDialog(); + + expect(getHelp()?.textContent).toContain('Mit der Regel „Leere Eingabe“ kann auch ein leeres Array []'); + save(); + expect(dialogRef.close).toHaveBeenCalledWith('ANY_OPEN'); + + const coding = CodingFactory.createCodingVariable('v1'); + coding.codes = [{ + id: 1, + type: 'FULL_CREDIT', + score: 1, + label: '', + ruleSets: [{ + valueArrayPos: dialogRef.close.calls.mostRecent().args[0] as RuleSet['valueArrayPos'], + ruleOperatorAnd: false, + rules: [{ method: 'IS_EMPTY', parameters: [] }] + }] + }]; + const varInfo: VariableInfo = { + id: 'v1', + type: 'string', + format: '', + multiple: true, + nullable: false, + values: [], + valuePositionLabels: [] + }; + expect(CodingSchemeFactory.validate([varInfo], [coding])).toEqual([]); + + const empty = CodingFactory.code({ id: 'v1', value: [], status: 'VALUE_CHANGED' }, coding); + expect(empty.status).toBe('CODING_COMPLETE'); + expect(empty.code).toBe(1); + const notEmpty = CodingFactory.code({ id: 'v1', value: ['Antwort'], status: 'VALUE_CHANGED' }, coding); + expect(notEmpty.status).toBe('CODING_INCOMPLETE'); + expect(notEmpty.code).toBeUndefined(); + }); + + it('labels a numeric reference as a specific position and keeps its zero-based value', async () => { + dialogData.value = 3; + const list = await renderDialog(); + + await expectAsync((await list.getItems({ selected: true }))[0].getText()).toBeResolvedTo('Bestimmte Position:'); + expect(fixture.nativeElement.querySelector('input[type="number"]').value).toBe('4'); + expect(getHelp()).toBeNull(); + + save(); + expect(dialogRef.close).toHaveBeenCalledWith(3); + }); + + it('keeps the existing fragment labels without array-specific options or explanations', async () => { + dialogData.isFragmentMode = true; + const list = await renderDialog(); + + expect((await list.getItems()).length).toBe(2); + await expectAsync((await list.getItems({ selected: true }))[0].getText()).toBeResolvedTo('Irgendeines'); + expect(getHelp()).toBeNull(); + + save(); + expect(dialogRef.close).toHaveBeenCalledWith('ANY'); + }); +}); diff --git a/projects/ngx-coding-components/src/lib/dialogs/select-code-rule-reference-dialog.component.ts b/projects/ngx-coding-components/src/lib/dialogs/select-code-rule-reference-dialog.component.ts index a98d31c..4712d50 100644 --- a/projects/ngx-coding-components/src/lib/dialogs/select-code-rule-reference-dialog.component.ts +++ b/projects/ngx-coding-components/src/lib/dialogs/select-code-rule-reference-dialog.component.ts @@ -22,13 +22,17 @@ export interface SelectCodeRuleReferenceDialogData {

{{ (refData.isFragmentMode ? 'rule' : 'rule-set') + '.reference.title' | translate }}

{{(refData.isFragmentMode ? 'rule' : 'rule-set') + '.reference.prompt' | translate}}
- - - {{(refData.isFragmentMode ? 'rule' : 'rule-set') + '.reference.any' | translate}} + + + + {{(refData.isFragmentMode ? 'rule' : 'rule-set') + '.reference.any' | translate}} + @if (!refData.isFragmentMode) { - - {{'rule-set.reference.any-open' | translate}} + + {{'rule-set.reference.any-open' | translate}} {{'rule-set.reference.sum' | translate}} @@ -37,10 +41,18 @@ export interface SelectCodeRuleReferenceDialogData { {{'rule-set.reference.length' | translate}} } - - {{(refData.isFragmentMode ? 'rule' : 'rule-set') + '.reference.specific' | translate}}: + + + {{(refData.isFragmentMode ? 'rule' : 'rule-set') + '.reference.specific' | translate}}: + + @if (!refData.isFragmentMode && (newSelection[0] === 'ANY' || newSelection[0] === 'ANY_OPEN')) { +

+ {{(newSelection[0] === 'ANY' ? 'rule-set.reference.any-help' : + 'rule-set.reference.any-open-help') | translate}} +

+ } {{'dialog-cancel' | translate}} `, + styles: [` + .reference-option { + height: auto; + min-height: 48px; + padding-top: 8px; + padding-bottom: 8px; + } + .reference-label { + display: block; + white-space: normal; + line-height: 1.4; + } + `], standalone: true, imports: [ MatDialogTitle, MatDialogContent, MatSelectionList, ReactiveFormsModule, FormsModule, diff --git a/projects/ngx-coding-components/translations/de.json b/projects/ngx-coding-components/translations/de.json index 6428339..b14374a 100644 --- a/projects/ngx-coding-components/translations/de.json +++ b/projects/ngx-coding-components/translations/de.json @@ -423,12 +423,14 @@ "reference": { "label": "Position im Werte-Array", "title": "Position im Werte-Array ändern", - "prompt": "Die Regeln dieses Regelsatzes beziehen sich auf ein Werte-Array insgesamt (Summe, Länge), auf irgendeinen Wert oder auf einen spezifischen.", - "any": "Irgendeine - Strikt", - "any-open": "Irgendeine - Offen", + "prompt": "Die Regeln beziehen sich auf alle vorhandenen Werte, mindestens einen Wert, eine bestimmte Position oder auf Summe bzw. Länge des Werte-Arrays.", + "any": "Alle vorhandenen Werte müssen passen", + "any-open": "Mindestens ein Wert muss passen", + "any-help": "Zusätzliche nicht passende Werte verhindern einen Treffer. Beispiel: [passend, nicht passend] → kein Treffer.", + "any-open-help": "Zusätzliche nicht passende Werte sind erlaubt. Beispiel: [passend, nicht passend] → Treffer. Ausnahme: Mit der Regel „Leere Eingabe“ kann auch ein leeres Array [] einen Treffer ergeben.", "sum": "Summe", "length": "Länge", - "specific": "Spezielle" + "specific": "Bestimmte Position" } }, "rule": { diff --git a/src/assets/de.json b/src/assets/de.json index 0f22f0a..bf62b85 100644 --- a/src/assets/de.json +++ b/src/assets/de.json @@ -195,11 +195,14 @@ "reference": { "label": "Position im Werte-Array", "title": "Position im Werte-Array ändern", - "prompt": "Die Regeln dieses Regelsatzes beziehen sich auf ein Werte-Array insgesamt (Summe), auf irgendeinen Wert oder auf einen spezifischen.", - "any": "Irgendeine", + "prompt": "Die Regeln beziehen sich auf alle vorhandenen Werte, mindestens einen Wert, eine bestimmte Position oder auf Summe bzw. Länge des Werte-Arrays.", + "any": "Alle vorhandenen Werte müssen passen", + "any-open": "Mindestens ein Wert muss passen", + "any-help": "Zusätzliche nicht passende Werte verhindern einen Treffer. Beispiel: [passend, nicht passend] → kein Treffer.", + "any-open-help": "Zusätzliche nicht passende Werte sind erlaubt. Beispiel: [passend, nicht passend] → Treffer. Ausnahme: Mit der Regel „Leere Eingabe“ kann auch ein leeres Array [] einen Treffer ergeben.", "sum": "Summe", "length": "Länge", - "specific": "Spezielle" + "specific": "Bestimmte Position" } }, "rule": { diff --git a/src/assets/en.json b/src/assets/en.json index e5ef891..0582ce0 100644 --- a/src/assets/en.json +++ b/src/assets/en.json @@ -1,4 +1,18 @@ { + "rule-set": { + "reference": { + "label": "Position in the value array", + "title": "Change position in the value array", + "prompt": "The rules apply to all existing values, at least one value, a specific position, or the sum or length of the value array.", + "any": "All existing values must match", + "any-open": "At least one value must match", + "any-help": "Additional non-matching values prevent a match. Example: [matching, non-matching] → no match.", + "any-open-help": "Additional non-matching values are allowed. Example: [matching, non-matching] → match. Exception: With the “Empty input” rule, an empty array [] can also match.", + "sum": "Sum", + "length": "Length", + "specific": "Specific position" + } + }, "derive-method": { "label": "Derivation method", "prompt": "Derivation method",