From c02c6acdc55120492f783c6b71d12ab2395e84d8 Mon Sep 17 00:00:00 2001 From: Alva Chien Date: Tue, 25 Aug 2026 20:47:09 +0800 Subject: [PATCH 1/7] feat(rating): treat unrated (0) as numeric in < / <= filters; drop HasAny/HasNone The rating < / <= filters previously excluded unrated (rating = 0) words via a `rating > 0 &&` guard, so e.g. `< 1` matched nothing despite 0 < 1 being true. Remove the guard so unrated words compare numerically like any other value. Since ratings are 1-5, `>= 1` now equals the former HasAny and `< 1` equals the former HasNone, so the redundant HasAny/HasNone operators are removed from the enum, matchRating, getRatingOperatorName, the three Select-by-Rating dialogs (and their isValueDisabled getter + template binding), and i18n. Vocabulary page (which never exposed HasAny/HasNone) and the chinese/translate/knowledge Select-by-Rating dialogs all route through the shared matchRating for consistent semantics. Tests updated. Co-Authored-By: Claude --- src/app/interfaces/ui-common.spec.ts | 19 ++--- src/app/interfaces/ui-common.ts | 24 ++---- src/app/interfaces/vocabulary.spec.ts | 4 +- ...inese-exercises-selectbyrating-dialog.html | 2 +- .../chinese-exercises.component.spec.ts | 64 +++++++--------- .../chinese-exercises.component.ts | 39 +--------- ...knowledge-exercises-list.component.spec.ts | 67 ++++++++--------- .../knowledge-exercises-list.component.ts | 41 +--------- ...ledge-exercises-selectbyrating-dialog.html | 2 +- ...slate-exercises-selectbyrating-dialog.html | 2 +- .../translate-exercises.component.spec.ts | 74 ++++++++----------- .../translate-exercises.component.ts | 41 +--------- src/assets/data/i18n/en.json | 4 +- src/assets/data/i18n/zh-CN.json | 4 +- 14 files changed, 116 insertions(+), 271 deletions(-) diff --git a/src/app/interfaces/ui-common.spec.ts b/src/app/interfaces/ui-common.spec.ts index 724f76a..98b9870 100644 --- a/src/app/interfaces/ui-common.spec.ts +++ b/src/app/interfaces/ui-common.spec.ts @@ -2,10 +2,10 @@ import { RatingOperatorEnum, matchRating } from './ui-common'; describe('ui-common.ts', () => { describe('matchRating', () => { - it('Equals matches only the exact rating', () => { + it('Equals matches only the exact rating, including unrated (0)', () => { expect(matchRating(3, RatingOperatorEnum.Equals, 3)).toBe(true); expect(matchRating(2, RatingOperatorEnum.Equals, 3)).toBe(false); - expect(matchRating(0, RatingOperatorEnum.Equals, 3)).toBe(false); + expect(matchRating(0, RatingOperatorEnum.Equals, 0)).toBe(true); }); it('GreaterThan / LargerOrEquals compare against the value', () => { @@ -15,19 +15,14 @@ describe('ui-common.ts', () => { expect(matchRating(2, RatingOperatorEnum.LargerOrEquals, 3)).toBe(false); }); - it('LessThan / LessOrEquals exclude unrated (0) words', () => { + it('LessThan / LessOrEquals compare against the value and include unrated (0)', () => { expect(matchRating(2, RatingOperatorEnum.LessThan, 3)).toBe(true); expect(matchRating(3, RatingOperatorEnum.LessThan, 3)).toBe(false); - expect(matchRating(0, RatingOperatorEnum.LessThan, 3)).toBe(false); + expect(matchRating(0, RatingOperatorEnum.LessThan, 3)).toBe(true); + expect(matchRating(0, RatingOperatorEnum.LessThan, 1)).toBe(true); + expect(matchRating(1, RatingOperatorEnum.LessThan, 1)).toBe(false); expect(matchRating(3, RatingOperatorEnum.LessOrEquals, 3)).toBe(true); - expect(matchRating(0, RatingOperatorEnum.LessOrEquals, 3)).toBe(false); - }); - - it('HasAny / HasNone test ratedness', () => { - expect(matchRating(1, RatingOperatorEnum.HasAny, 0)).toBe(true); - expect(matchRating(0, RatingOperatorEnum.HasAny, 0)).toBe(false); - expect(matchRating(0, RatingOperatorEnum.HasNone, 0)).toBe(true); - expect(matchRating(4, RatingOperatorEnum.HasNone, 0)).toBe(false); + expect(matchRating(0, RatingOperatorEnum.LessOrEquals, 3)).toBe(true); }); }); }); diff --git a/src/app/interfaces/ui-common.ts b/src/app/interfaces/ui-common.ts index f0916ac..9a5b609 100644 --- a/src/app/interfaces/ui-common.ts +++ b/src/app/interfaces/ui-common.ts @@ -21,8 +21,6 @@ export enum RatingOperatorEnum { 'Equals' = 0, 'GreaterThan' = 1, 'LessThan' = 2, - 'HasAny' = 3, - 'HasNone' = 4, 'LargerOrEquals' = 5, 'LessOrEquals' = 6, } @@ -57,10 +55,6 @@ export const getRatingOperatorName = (operator: RatingOperatorEnum): string => { return 'Greater Than'; case RatingOperatorEnum.LessThan: return 'Less Than'; - case RatingOperatorEnum.HasAny: - return 'Has Any'; - case RatingOperatorEnum.HasNone: - return 'Has None'; case RatingOperatorEnum.LargerOrEquals: return 'Larger or Equals'; case RatingOperatorEnum.LessOrEquals: @@ -72,10 +66,12 @@ export const getRatingOperatorName = (operator: RatingOperatorEnum): string => { /** * Shared rating comparison used by the vocabulary list filter bar (rating - * conditions) so all rating matching goes through one place. - * Note: LessThan / LessOrEquals intentionally exclude unrated (0) words — a - * rating of 0 means "not yet assessed", which is covered by HasNone. This keeps - * LessThan 1 from collapsing into HasNone. + * conditions) and the Select-by-Rating dialogs, so all rating matching goes + * through one place. An unrated word has rating 0 and is compared numerically + * like any other value: `< 1` / `<= 0` match unrated words, `>= 1` matches any + * rated word. (Ratings are 1–5, so `< 1` is equivalent to the former "HasNone" + * and `>= 1` to the former "HasAny", which is why those operators were + * dropped.) */ export const matchRating = ( rating: number, @@ -90,13 +86,9 @@ export const matchRating = ( case RatingOperatorEnum.LargerOrEquals: return rating >= value; case RatingOperatorEnum.LessThan: - return rating > 0 && rating < value; + return rating < value; case RatingOperatorEnum.LessOrEquals: - return rating > 0 && rating <= value; - case RatingOperatorEnum.HasAny: - return rating > 0; - case RatingOperatorEnum.HasNone: - return rating === 0; + return rating <= value; default: return false; } diff --git a/src/app/interfaces/vocabulary.spec.ts b/src/app/interfaces/vocabulary.spec.ts index 88168ea..43cc62a 100644 --- a/src/app/interfaces/vocabulary.spec.ts +++ b/src/app/interfaces/vocabulary.spec.ts @@ -150,13 +150,13 @@ describe('vocabulary.ts', () => { expect(matchVocabularyListFilter(item, 5, filter)).toBe(true); expect(matchVocabularyListFilter(item, 4, filter)).toBe(true); expect(matchVocabularyListFilter(item, 3, filter)).toBe(false); - // Unrated words are excluded from LessThan / LessOrEquals. + // Unrated (0) words compare numerically: 0 < 2 is true. expect( matchVocabularyListFilter(item, 0, { ...baseFilter, ratingConditions: [{ operator: RatingOperatorEnum.LessThan, value: 2 }], }) - ).toBe(false); + ).toBe(true); }); it('ANDs free text, word and rating conditions', () => { diff --git a/src/app/pages/chinese-exercises/chinese-exercises-selectbyrating-dialog.html b/src/app/pages/chinese-exercises/chinese-exercises-selectbyrating-dialog.html index 10082ed..6f0a461 100644 --- a/src/app/pages/chinese-exercises/chinese-exercises-selectbyrating-dialog.html +++ b/src/app/pages/chinese-exercises/chinese-exercises-selectbyrating-dialog.html @@ -11,7 +11,7 @@

{{ t('selectByRating') }}

{{ t('selection.ratingValue') }} - + @for (val of ratingValues; track val) { {{ val }} } diff --git a/src/app/pages/chinese-exercises/chinese-exercises.component.spec.ts b/src/app/pages/chinese-exercises/chinese-exercises.component.spec.ts index 0c1220c..dd8b9ba 100644 --- a/src/app/pages/chinese-exercises/chinese-exercises.component.spec.ts +++ b/src/app/pages/chinese-exercises/chinese-exercises.component.spec.ts @@ -616,9 +616,10 @@ describe('ChineseExercisesComponent', () => { expect(component.selection.selected[0].id).toBe(1); }); - it('should select items with any rating', () => { + it('should select items with any rating (>= 1)', () => { + // ratings: id1=5, id2=3, id3=0. LargerOrEquals 1 → id1, id2 (any rated). const mockDialogRef = { - afterClosed: () => of({ ratingOperator: RatingOperatorEnum.HasAny }), + afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LargerOrEquals, ratingValue: 1 }), } as MatDialogRef; dialogSpy.open.mockReturnValue(mockDialogRef); @@ -627,9 +628,10 @@ describe('ChineseExercisesComponent', () => { expect(component.selection.selected.length).toBe(2); }); - it('should select items with no rating', () => { + it('should select items with no rating (< 1)', () => { + // ratings: id1=5, id2=3, id3=0. LessThan 1 → only id3 (unrated, 0 < 1). const mockDialogRef = { - afterClosed: () => of({ ratingOperator: RatingOperatorEnum.HasNone }), + afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 1 }), } as MatDialogRef; dialogSpy.open.mockReturnValue(mockDialogRef); @@ -639,9 +641,9 @@ describe('ChineseExercisesComponent', () => { expect(component.selection.selected[0].id).toBe(3); }); - it('should select rated items below the value, excluding unrated (0)', () => { - // ratings: id1=5, id2=3, id3=0. LessThan 4 → only id2 (rating 3); - // id3 (unrated, 0) is deliberately excluded (covered by HasNone). + it('should select items with rating below the value, including unrated (0)', () => { + // ratings: id1=5, id2=3, id3=0. LessThan 4 → id2 (3) and id3 (0); + // unrated (0) compares numerically (0 < 4); use < 1 for unrated-only. const mockDialogRef = { afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 4 }), } as MatDialogRef; @@ -649,12 +651,13 @@ describe('ChineseExercisesComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(1); - expect(component.selection.selected[0].id).toBe(2); + expect(component.selection.selected.length).toBe(2); + expect(component.selection.selected.some(i => i.id === 2)).toBe(true); + expect(component.selection.selected.some(i => i.id === 3)).toBe(true); }); - it('should select nothing when no rated item is below the value', () => { - // ratings: id1=5, id2=3, id3=0. LessThan 2 → no rated item qualifies. + it('should select only unrated (0) items when no rated item is below the value', () => { + // ratings: id1=5, id2=3, id3=0. LessThan 2 → only id3 (unrated, 0 < 2). const mockDialogRef = { afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 2 }), } as MatDialogRef; @@ -662,7 +665,8 @@ describe('ChineseExercisesComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(0); + expect(component.selection.selected.length).toBe(1); + expect(component.selection.selected[0].id).toBe(3); }); it('should select items with rating larger or equals 3', () => { @@ -680,9 +684,9 @@ describe('ChineseExercisesComponent', () => { expect(component.selection.selected.some(i => i.id === 2)).toBe(true); }); - it('should select items with rating less or equals 3, excluding unrated', () => { - // ratings: id1=5, id2=3, id3=0. LessOrEquals 3 → only id2 (rating 3); - // id3 (unrated, 0) is deliberately excluded (covered by HasNone). + it('should select items with rating less or equals 3, including unrated', () => { + // ratings: id1=5, id2=3, id3=0. LessOrEquals 3 → id2 (3) and id3 (0); + // unrated (0) compares numerically (0 <= 3). const mockDialogRef = { afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessOrEquals, ratingValue: 3 }), } as MatDialogRef; @@ -690,13 +694,13 @@ describe('ChineseExercisesComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(1); - expect(component.selection.selected[0].id).toBe(2); + expect(component.selection.selected.length).toBe(2); + expect(component.selection.selected.some(i => i.id === 2)).toBe(true); + expect(component.selection.selected.some(i => i.id === 3)).toBe(true); }); - it('should select nothing for less or equals when no rated item is at or below the value', () => { - // ratings: id1=5, id2=3, id3=0. LessOrEquals 2 → no rated item qualifies; - // id3 (unrated, 0) is excluded, confirming it does not collapse into HasNone. + it('should select only unrated (0) for less or equals when no rated item is at or below the value', () => { + // ratings: id1=5, id2=3, id3=0. LessOrEquals 2 → only id3 (unrated, 0 <= 2). const mockDialogRef = { afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessOrEquals, ratingValue: 2 }), } as MatDialogRef; @@ -704,7 +708,8 @@ describe('ChineseExercisesComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(0); + expect(component.selection.selected.length).toBe(1); + expect(component.selection.selected[0].id).toBe(3); }); it('should preserve the existing selection when the dialog is cancelled', () => { @@ -1026,25 +1031,10 @@ describe('ChineseSelectByRatingDialogComponent', () => { }); }); - it('isValueDisabled should return true when HasAny operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.HasAny); - expect(component.isValueDisabled).toBe(true); - }); - - it('isValueDisabled should return true when HasNone operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.HasNone); - expect(component.isValueDisabled).toBe(true); - }); - - it('isValueDisabled should return false when Equals operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.Equals); - expect(component.isValueDisabled).toBe(false); - }); - it('ratingOperators should include Larger or Equals and Less or Equals', () => { const values = component.ratingOperators.map(op => op.value); expect(values).toContain(RatingOperatorEnum.LargerOrEquals); expect(values).toContain(RatingOperatorEnum.LessOrEquals); - expect(component.ratingOperators.length).toBe(7); + expect(component.ratingOperators.length).toBe(5); }); }); diff --git a/src/app/pages/chinese-exercises/chinese-exercises.component.ts b/src/app/pages/chinese-exercises/chinese-exercises.component.ts index f76c340..3520769 100644 --- a/src/app/pages/chinese-exercises/chinese-exercises.component.ts +++ b/src/app/pages/chinese-exercises/chinese-exercises.component.ts @@ -66,6 +66,7 @@ import { getAllQuestionBankLevelEnumValues, convertChineseReciteItemToKnowledge, RatingOperatorEnum, + matchRating, } from '../../interfaces'; import { LearningContentService, LearningRatingService, UIService } from '../../services'; import { FooterComponent } from '../../shared/footer/footer'; @@ -461,36 +462,7 @@ export class ChineseExercisesComponent implements OnInit { this.dataSource.data.forEach(item => { const rating = this.getRating(item.id); - let matches = false; - - switch (operator) { - case RatingOperatorEnum.Equals: - matches = rating === value; - break; - case RatingOperatorEnum.GreaterThan: - matches = rating > value; - break; - case RatingOperatorEnum.LargerOrEquals: - matches = rating >= value; - break; - case RatingOperatorEnum.LessThan: - // "Less than" intentionally excludes unrated (0) items: a rating - // of 0 means "not yet assessed", which is covered by HasNone. - // This keeps LessThan 1 from collapsing into HasNone. - matches = rating > 0 && rating < value; - break; - case RatingOperatorEnum.LessOrEquals: - // Same unrated-exclusion rationale as LessThan: an unrated (0) - // item is "not yet assessed", not "rated at or below the value". - matches = rating > 0 && rating <= value; - break; - case RatingOperatorEnum.HasAny: - matches = rating > 0; - break; - case RatingOperatorEnum.HasNone: - matches = rating === 0; - break; - } + const matches = matchRating(rating, operator, value); if (matches) { this.selection.select(item); @@ -642,8 +614,6 @@ export class ChineseSelectByRatingDialogComponent { { value: RatingOperatorEnum.LargerOrEquals, label: 'operatorLargerOrEquals' }, { value: RatingOperatorEnum.LessThan, label: 'operatorLessThan' }, { value: RatingOperatorEnum.LessOrEquals, label: 'operatorLessOrEquals' }, - { value: RatingOperatorEnum.HasAny, label: 'operatorHasAny' }, - { value: RatingOperatorEnum.HasNone, label: 'operatorHasNone' }, ]; } @@ -651,11 +621,6 @@ export class ChineseSelectByRatingDialogComponent { return [1, 2, 3, 4, 5]; } - get isValueDisabled(): boolean { - return this.ratingOperator() === RatingOperatorEnum.HasAny || - this.ratingOperator() === RatingOperatorEnum.HasNone; - } - onNoClick(): void { this.dialogRef.close(); } diff --git a/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-list.component.spec.ts b/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-list.component.spec.ts index dd4ea0d..9a23fa4 100644 --- a/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-list.component.spec.ts +++ b/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-list.component.spec.ts @@ -909,9 +909,9 @@ describe('KnowledgeExercisesListComponent', () => { expect(component.selection.selected.some(i => i.id === '20')).toBe(true); }); - it('should select rated items below the value, excluding unrated (0)', () => { - // ratings: id10=5, id20=3, id30=0. LessThan 4 → only id20 (rating 3); - // id30 (unrated, 0) is deliberately excluded (covered by HasNone). + it('should select items with rating below the value, including unrated (0)', () => { + // ratings: id10=5, id20=3, id30=0. LessThan 4 → id20 (3) and id30 (0); + // unrated (0) compares numerically (0 < 4); use < 1 for unrated-only. const mockDialogRef = { afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 4 }), }; @@ -919,11 +919,13 @@ describe('KnowledgeExercisesListComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(1); - expect(component.selection.selected[0].id).toBe('20'); + expect(component.selection.selected.length).toBe(2); + expect(component.selection.selected.some(i => i.id === '20')).toBe(true); + expect(component.selection.selected.some(i => i.id === '30')).toBe(true); }); - it('should select nothing when no rated item is below the value', () => { + it('should select only unrated (0) items when no rated item is below the value', () => { + // ratings: id10=5, id20=3, id30=0. LessThan 2 → only id30 (unrated, 0 < 2). const mockDialogRef = { afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 2 }), }; @@ -931,10 +933,13 @@ describe('KnowledgeExercisesListComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(0); + expect(component.selection.selected.length).toBe(1); + expect(component.selection.selected[0].id).toBe('30'); }); - it('should select items with rating less or equals 3, excluding unrated', () => { + it('should select items with rating less or equals 3, including unrated', () => { + // ratings: id10=5, id20=3, id30=0. LessOrEquals 3 → id20 (3) and id30 (0); + // unrated (0) compares numerically (0 <= 3). const mockDialogRef = { afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessOrEquals, ratingValue: 3 }), @@ -943,11 +948,13 @@ describe('KnowledgeExercisesListComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(1); - expect(component.selection.selected[0].id).toBe('20'); + expect(component.selection.selected.length).toBe(2); + expect(component.selection.selected.some(i => i.id === '20')).toBe(true); + expect(component.selection.selected.some(i => i.id === '30')).toBe(true); }); - it('should select nothing for less or equals when no rated item is at or below the value', () => { + it('should select only unrated (0) for less or equals when no rated item is at or below the value', () => { + // ratings: id10=5, id20=3, id30=0. LessOrEquals 2 → only id30 (unrated, 0 <= 2). const mockDialogRef = { afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessOrEquals, ratingValue: 2 }), @@ -956,12 +963,14 @@ describe('KnowledgeExercisesListComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(0); + expect(component.selection.selected.length).toBe(1); + expect(component.selection.selected[0].id).toBe('30'); }); - it('should select items with any rating', () => { + it('should select items with any rating (>= 1)', () => { + // ratings: id10=5, id20=3, id30=0. LargerOrEquals 1 → id10, id20. const mockDialogRef = { - afterClosed: () => of({ ratingOperator: RatingOperatorEnum.HasAny }), + afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LargerOrEquals, ratingValue: 1 }), }; vi.spyOn(component.dialog, 'open').mockReturnValue(mockDialogRef as any); @@ -970,9 +979,10 @@ describe('KnowledgeExercisesListComponent', () => { expect(component.selection.selected.length).toBe(2); }); - it('should select items with no rating', () => { + it('should select items with no rating (< 1)', () => { + // ratings: id10=5, id20=3, id30=0. LessThan 1 → only id30 (unrated, 0 < 1). const mockDialogRef = { - afterClosed: () => of({ ratingOperator: RatingOperatorEnum.HasNone }), + afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 1 }), }; vi.spyOn(component.dialog, 'open').mockReturnValue(mockDialogRef as any); @@ -985,7 +995,7 @@ describe('KnowledgeExercisesListComponent', () => { it('should clear the previous selection before applying the rating match', () => { component.selection.select(mockContentWithIDs[0]); const mockDialogRef = { - afterClosed: () => of({ ratingOperator: RatingOperatorEnum.HasNone }), + afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 1 }), }; vi.spyOn(component.dialog, 'open').mockReturnValue(mockDialogRef as any); @@ -1010,7 +1020,7 @@ describe('KnowledgeExercisesListComponent', () => { const markForCheckSpy = vi.spyOn(component['cdr'], 'markForCheck'); markForCheckSpy.mockClear(); const mockDialogRef = { - afterClosed: () => of({ ratingOperator: RatingOperatorEnum.HasAny }), + afterClosed: () => of({ ratingOperator: RatingOperatorEnum.LargerOrEquals, ratingValue: 1 }), }; vi.spyOn(component.dialog, 'open').mockReturnValue(mockDialogRef as any); @@ -1509,30 +1519,13 @@ describe('KnowledgeSelectByRatingDialogComponent', () => { }); }); - it('isValueDisabled should return true when HasAny operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.HasAny); - expect(component.isValueDisabled).toBe(true); - }); - - it('isValueDisabled should return true when HasNone operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.HasNone); - expect(component.isValueDisabled).toBe(true); - }); - - it('isValueDisabled should return false when Equals operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.Equals); - expect(component.isValueDisabled).toBe(false); - }); - - it('ratingOperators should include all seven operators', () => { + it('ratingOperators should include all five value-based operators', () => { const values = component.ratingOperators.map(op => op.value); expect(values).toContain(RatingOperatorEnum.Equals); expect(values).toContain(RatingOperatorEnum.GreaterThan); expect(values).toContain(RatingOperatorEnum.LargerOrEquals); expect(values).toContain(RatingOperatorEnum.LessThan); expect(values).toContain(RatingOperatorEnum.LessOrEquals); - expect(values).toContain(RatingOperatorEnum.HasAny); - expect(values).toContain(RatingOperatorEnum.HasNone); - expect(component.ratingOperators.length).toBe(7); + expect(component.ratingOperators.length).toBe(5); }); }); diff --git a/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-list.component.ts b/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-list.component.ts index 3815256..5a6f90b 100644 --- a/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-list.component.ts +++ b/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-list.component.ts @@ -58,6 +58,7 @@ import { convertQuestionBankItemToMarkdown, QuestionBankTypeEnum, RatingOperatorEnum, + matchRating, } from '../../../interfaces'; interface FilterResult { @@ -683,36 +684,7 @@ export class KnowledgeExercisesListComponent implements OnInit { this.dataSource.data.forEach(item => { const rating = this.getRating(item.id); - let matches = false; - - switch (operator) { - case RatingOperatorEnum.Equals: - matches = rating === value; - break; - case RatingOperatorEnum.GreaterThan: - matches = rating > value; - break; - case RatingOperatorEnum.LargerOrEquals: - matches = rating >= value; - break; - case RatingOperatorEnum.LessThan: - // "Less than" intentionally excludes unrated (0) items: a rating - // of 0 means "not yet assessed", which is covered by HasNone. - // This keeps LessThan 1 from collapsing into HasNone. - matches = rating > 0 && rating < value; - break; - case RatingOperatorEnum.LessOrEquals: - // Same unrated-exclusion rationale as LessThan: an unrated (0) - // item is "not yet assessed", not "rated at or below the value". - matches = rating > 0 && rating <= value; - break; - case RatingOperatorEnum.HasAny: - matches = rating > 0; - break; - case RatingOperatorEnum.HasNone: - matches = rating === 0; - break; - } + const matches = matchRating(rating, operator, value); if (matches) { this.selection.select(item); @@ -957,8 +929,6 @@ export class KnowledgeSelectByRatingDialogComponent { { value: RatingOperatorEnum.LargerOrEquals, label: 'operatorLargerOrEquals' }, { value: RatingOperatorEnum.LessThan, label: 'operatorLessThan' }, { value: RatingOperatorEnum.LessOrEquals, label: 'operatorLessOrEquals' }, - { value: RatingOperatorEnum.HasAny, label: 'operatorHasAny' }, - { value: RatingOperatorEnum.HasNone, label: 'operatorHasNone' }, ]; } @@ -966,13 +936,6 @@ export class KnowledgeSelectByRatingDialogComponent { return [1, 2, 3, 4, 5]; } - get isValueDisabled(): boolean { - return ( - this.ratingOperator() === RatingOperatorEnum.HasAny || - this.ratingOperator() === RatingOperatorEnum.HasNone - ); - } - onNoClick(): void { this.dialogRef.close(); } diff --git a/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-selectbyrating-dialog.html b/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-selectbyrating-dialog.html index 10082ed..6f0a461 100644 --- a/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-selectbyrating-dialog.html +++ b/src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-selectbyrating-dialog.html @@ -11,7 +11,7 @@

{{ t('selectByRating') }}

{{ t('selection.ratingValue') }} - + @for (val of ratingValues; track val) { {{ val }} } diff --git a/src/app/pages/translate-exercises/translate-exercises-selectbyrating-dialog.html b/src/app/pages/translate-exercises/translate-exercises-selectbyrating-dialog.html index 10082ed..6f0a461 100644 --- a/src/app/pages/translate-exercises/translate-exercises-selectbyrating-dialog.html +++ b/src/app/pages/translate-exercises/translate-exercises-selectbyrating-dialog.html @@ -11,7 +11,7 @@

{{ t('selectByRating') }}

{{ t('selection.ratingValue') }} - + @for (val of ratingValues; track val) { {{ val }} } diff --git a/src/app/pages/translate-exercises/translate-exercises.component.spec.ts b/src/app/pages/translate-exercises/translate-exercises.component.spec.ts index c4e3c46..333f417 100644 --- a/src/app/pages/translate-exercises/translate-exercises.component.spec.ts +++ b/src/app/pages/translate-exercises/translate-exercises.component.spec.ts @@ -647,9 +647,10 @@ describe('TranslateExercisesComponent', () => { expect(component.selection.selected[0].id).toBe('1'); }); - it('should select items with any rating', () => { + it('should select items with any rating (>= 1)', () => { + // ratings: id1=5, id2=3, id3=0. LargerOrEquals 1 → id1, id2. const mockDialogRef = { - afterClosed: vi.fn().mockReturnValue(of({ ratingOperator: RatingOperatorEnum.HasAny })), + afterClosed: vi.fn().mockReturnValue(of({ ratingOperator: RatingOperatorEnum.LargerOrEquals, ratingValue: 1 })), }; mockDialog.open.mockReturnValue(mockDialogRef); @@ -658,9 +659,10 @@ describe('TranslateExercisesComponent', () => { expect(component.selection.selected.length).toBe(2); }); - it('should select items with no rating', () => { + it('should select items with no rating (< 1)', () => { + // ratings: id1=5, id2=3, id3=0. LessThan 1 → only id3 (unrated, 0 < 1). const mockDialogRef = { - afterClosed: vi.fn().mockReturnValue(of({ ratingOperator: RatingOperatorEnum.HasNone })), + afterClosed: vi.fn().mockReturnValue(of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 1 })), }; mockDialog.open.mockReturnValue(mockDialogRef); @@ -670,9 +672,9 @@ describe('TranslateExercisesComponent', () => { expect(component.selection.selected[0].id).toBe('3'); }); - it('should select rated items below the value, excluding unrated (0)', () => { - // ratings: id1=5, id2=3, id3=0. LessThan 4 → only id2 (rating 3); - // id3 (unrated, 0) is deliberately excluded (covered by HasNone). + it('should select items with rating below the value, including unrated (0)', () => { + // ratings: id1=5, id2=3, id3=0. LessThan 4 → id2 (3) and id3 (0); + // unrated (0) compares numerically (0 < 4); use < 1 for unrated-only. const mockDialogRef = { afterClosed: vi .fn() @@ -682,12 +684,13 @@ describe('TranslateExercisesComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(1); - expect(component.selection.selected[0].id).toBe('2'); + expect(component.selection.selected.length).toBe(2); + expect(component.selection.selected.some(i => i.id === '2')).toBe(true); + expect(component.selection.selected.some(i => i.id === '3')).toBe(true); }); - it('should select nothing when no rated item is below the value', () => { - // ratings: id1=5, id2=3, id3=0. LessThan 2 → no rated item qualifies. + it('should select only unrated (0) items when no rated item is below the value', () => { + // ratings: id1=5, id2=3, id3=0. LessThan 2 → only id3 (unrated, 0 < 2). const mockDialogRef = { afterClosed: vi .fn() @@ -697,7 +700,8 @@ describe('TranslateExercisesComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(0); + expect(component.selection.selected.length).toBe(1); + expect(component.selection.selected[0].id).toBe('3'); }); it('should select items with rating larger or equals 3', () => { @@ -718,9 +722,9 @@ describe('TranslateExercisesComponent', () => { expect(component.selection.selected.some(i => i.id === '2')).toBe(true); }); - it('should select items with rating less or equals 3, excluding unrated', () => { - // ratings: id1=5, id2=3, id3=0. LessOrEquals 3 → only id2 (rating 3); - // id3 (unrated, 0) is deliberately excluded (covered by HasNone). + it('should select items with rating less or equals 3, including unrated', () => { + // ratings: id1=5, id2=3, id3=0. LessOrEquals 3 → id2 (3) and id3 (0); + // unrated (0) compares numerically (0 <= 3). const mockDialogRef = { afterClosed: vi .fn() @@ -730,13 +734,13 @@ describe('TranslateExercisesComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(1); - expect(component.selection.selected[0].id).toBe('2'); + expect(component.selection.selected.length).toBe(2); + expect(component.selection.selected.some(i => i.id === '2')).toBe(true); + expect(component.selection.selected.some(i => i.id === '3')).toBe(true); }); - it('should select nothing for less or equals when no rated item is at or below the value', () => { - // ratings: id1=5, id2=3, id3=0. LessOrEquals 2 → no rated item qualifies; - // id3 (unrated, 0) is excluded, confirming it does not collapse into HasNone. + it('should select only unrated (0) for less or equals when no rated item is at or below the value', () => { + // ratings: id1=5, id2=3, id3=0. LessOrEquals 2 → only id3 (unrated, 0 <= 2). const mockDialogRef = { afterClosed: vi .fn() @@ -746,7 +750,8 @@ describe('TranslateExercisesComponent', () => { component.onSelectByRating(); - expect(component.selection.selected.length).toBe(0); + expect(component.selection.selected.length).toBe(1); + expect(component.selection.selected[0].id).toBe('3'); }); it('should clear the previous selection before applying the new rating match', () => { @@ -754,13 +759,13 @@ describe('TranslateExercisesComponent', () => { expect(component.selection.selected.length).toBe(1); const mockDialogRef = { - afterClosed: vi.fn().mockReturnValue(of({ ratingOperator: RatingOperatorEnum.HasNone })), + afterClosed: vi.fn().mockReturnValue(of({ ratingOperator: RatingOperatorEnum.LessThan, ratingValue: 1 })), }; mockDialog.open.mockReturnValue(mockDialogRef); component.onSelectByRating(); - // HasNone matches only id3; the previously selected id1 is dropped. + // < 1 matches only id3 (unrated); the previously selected id1 is dropped. expect(component.selection.selected.length).toBe(1); expect(component.selection.selected[0].id).toBe('3'); }); @@ -784,7 +789,7 @@ describe('TranslateExercisesComponent', () => { const markForCheckSpy = vi.spyOn(component['cdr'], 'markForCheck'); markForCheckSpy.mockClear(); const mockDialogRef = { - afterClosed: vi.fn().mockReturnValue(of({ ratingOperator: RatingOperatorEnum.HasAny })), + afterClosed: vi.fn().mockReturnValue(of({ ratingOperator: RatingOperatorEnum.LargerOrEquals, ratingValue: 1 })), }; mockDialog.open.mockReturnValue(mockDialogRef); @@ -1197,30 +1202,13 @@ describe('TranslateSelectByRatingDialogComponent', () => { }); }); - it('isValueDisabled should return true when HasAny operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.HasAny); - expect(component.isValueDisabled).toBe(true); - }); - - it('isValueDisabled should return true when HasNone operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.HasNone); - expect(component.isValueDisabled).toBe(true); - }); - - it('isValueDisabled should return false when Equals operator is selected', () => { - component.ratingOperator.set(RatingOperatorEnum.Equals); - expect(component.isValueDisabled).toBe(false); - }); - - it('ratingOperators should include all seven operators', () => { + it('ratingOperators should include all five value-based operators', () => { const values = component.ratingOperators.map(op => op.value); expect(values).toContain(RatingOperatorEnum.Equals); expect(values).toContain(RatingOperatorEnum.GreaterThan); expect(values).toContain(RatingOperatorEnum.LargerOrEquals); expect(values).toContain(RatingOperatorEnum.LessThan); expect(values).toContain(RatingOperatorEnum.LessOrEquals); - expect(values).toContain(RatingOperatorEnum.HasAny); - expect(values).toContain(RatingOperatorEnum.HasNone); - expect(component.ratingOperators.length).toBe(7); + expect(component.ratingOperators.length).toBe(5); }); }); diff --git a/src/app/pages/translate-exercises/translate-exercises.component.ts b/src/app/pages/translate-exercises/translate-exercises.component.ts index b82ef89..f8593c1 100644 --- a/src/app/pages/translate-exercises/translate-exercises.component.ts +++ b/src/app/pages/translate-exercises/translate-exercises.component.ts @@ -65,6 +65,7 @@ import { getAllPrintExecDateString, QuestionBankTypeEnum, RatingOperatorEnum, + matchRating, } from '../../interfaces'; import { LearningContentService, @@ -615,36 +616,7 @@ export class TranslateExercisesComponent implements OnInit { this.dataSource.data.forEach(item => { const rating = this.getRating(item.id); - let matches = false; - - switch (operator) { - case RatingOperatorEnum.Equals: - matches = rating === value; - break; - case RatingOperatorEnum.GreaterThan: - matches = rating > value; - break; - case RatingOperatorEnum.LargerOrEquals: - matches = rating >= value; - break; - case RatingOperatorEnum.LessThan: - // "Less than" intentionally excludes unrated (0) items: a rating - // of 0 means "not yet assessed", which is covered by HasNone. - // This keeps LessThan 1 from collapsing into HasNone. - matches = rating > 0 && rating < value; - break; - case RatingOperatorEnum.LessOrEquals: - // Same unrated-exclusion rationale as LessThan: an unrated (0) - // item is "not yet assessed", not "rated at or below the value". - matches = rating > 0 && rating <= value; - break; - case RatingOperatorEnum.HasAny: - matches = rating > 0; - break; - case RatingOperatorEnum.HasNone: - matches = rating === 0; - break; - } + const matches = matchRating(rating, operator, value); if (matches) { this.selection.select(item); @@ -935,8 +907,6 @@ export class TranslateSelectByRatingDialogComponent { { value: RatingOperatorEnum.LargerOrEquals, label: 'operatorLargerOrEquals' }, { value: RatingOperatorEnum.LessThan, label: 'operatorLessThan' }, { value: RatingOperatorEnum.LessOrEquals, label: 'operatorLessOrEquals' }, - { value: RatingOperatorEnum.HasAny, label: 'operatorHasAny' }, - { value: RatingOperatorEnum.HasNone, label: 'operatorHasNone' }, ]; } @@ -944,13 +914,6 @@ export class TranslateSelectByRatingDialogComponent { return [1, 2, 3, 4, 5]; } - get isValueDisabled(): boolean { - return ( - this.ratingOperator() === RatingOperatorEnum.HasAny || - this.ratingOperator() === RatingOperatorEnum.HasNone - ); - } - onNoClick(): void { this.dialogRef.close(); } diff --git a/src/assets/data/i18n/en.json b/src/assets/data/i18n/en.json index 249264f..b799c57 100644 --- a/src/assets/data/i18n/en.json +++ b/src/assets/data/i18n/en.json @@ -336,9 +336,7 @@ "operatorGreaterThan": "Greater Than", "operatorLessThan": "Less Than", "operatorLargerOrEquals": "Larger or Equals", - "operatorLessOrEquals": "Less or Equals", - "operatorHasAny": "Has Any Rating", - "operatorHasNone": "Has No Rating" + "operatorLessOrEquals": "Less or Equals" }, "auth": { "login": "Login", diff --git a/src/assets/data/i18n/zh-CN.json b/src/assets/data/i18n/zh-CN.json index 82919a9..dfa7cee 100644 --- a/src/assets/data/i18n/zh-CN.json +++ b/src/assets/data/i18n/zh-CN.json @@ -334,9 +334,7 @@ "operatorGreaterThan": "大于", "operatorLessThan": "小于", "operatorLargerOrEquals": "大于等于", - "operatorLessOrEquals": "小于等于", - "operatorHasAny": "有评分", - "operatorHasNone": "无评分" + "operatorLessOrEquals": "小于等于" }, "auth": { "login": "登录", From a0737f2cca2efdc3f9c8645316bea987b967dfc8 Mon Sep 17 00:00:00 2001 From: Alva Chien Date: Tue, 25 Aug 2026 20:49:46 +0800 Subject: [PATCH 2/7] docs(vocabulary): reflect unrated-as-0 semantics and HasAny/HasNone removal Update the rating-filter matching description (unrated 0 now compared numerically; < 1 matches unrated, >= 1 matches any rated) and the RatingOperatorEnum member list (HasAny/HasNone dropped) in vocabulary-exercises-architecture.md to match the code change in the prior commit. Co-Authored-By: Claude --- docs/vocabulary-exercises-architecture.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/vocabulary-exercises-architecture.md b/docs/vocabulary-exercises-architecture.md index e73cdac..0b49c61 100644 --- a/docs/vocabulary-exercises-architecture.md +++ b/docs/vocabulary-exercises-architecture.md @@ -426,7 +426,7 @@ Matching is `freeText AND every active word condition AND every rating condition - `freeText`: lowercased substring of `${id}${enword}${cnword}` (mirrors the old default `MatTableDataSource` predicate). - `wordConditions`: text operators compare lowercased `enword`; phrase operators match on shape (`isPhrase` = `enword` contains a space; `notPhrase` = the negation) and are always active. -- `ratingConditions`: each delegated to `matchRating(rating, operator, value)` (`ui-common.ts`); `LessThan`/`LessOrEquals` intentionally exclude unrated (`0`) words so "LessThan 1" does not collapse into "HasNone". +- `ratingConditions`: each delegated to `matchRating(rating, operator, value)` (`ui-common.ts`). An unrated word has rating `0` and is compared numerically like any other value, so `< 1` matches unrated words and `>= 1` matches any rated word (ratings are 1–5). The Rating filter dialog offers only the five value-based operators (`>=`, `>`, `=`, `<=`, `<`) with values 1–5; the former `HasAny`/`HasNone` operators were removed as redundant (`>= 1` ≡ HasAny, `< 1` ≡ HasNone). ### 8.2 Wiring through `MatTableDataSource` @@ -544,7 +544,7 @@ interface RatingCondition { operator: RatingOperatorEnum; value: number; } interface VocabularyListFilter { freeText: string; wordConditions: WordCondition[]; ratingConditions: RatingCondition[]; } ``` -`RatingOperatorEnum` (`Equals`, `GreaterThan`, `LessThan`, `HasAny`, `HasNone`, `LargerOrEquals`, `LessOrEquals`) and `SelectionModeEnum` (`ByID`, `FreeSelection`, `ByCount`) live in `ui-common.ts`, alongside `matchRating()`. +`RatingOperatorEnum` (`Equals`, `GreaterThan`, `LessThan`, `LargerOrEquals`, `LessOrEquals`) and `SelectionModeEnum` (`ByID`, `FreeSelection`, `ByCount`) live in `ui-common.ts`, alongside `matchRating()`, which compares an unrated (`0`) word numerically. ### 10.6 Pure helpers From 8f5fff56383ad91d9ac31cf19c01c5a0d80b8dd2 Mon Sep 17 00:00:00 2001 From: Alva Chien Date: Tue, 25 Aug 2026 20:53:48 +0800 Subject: [PATCH 3/7] chore: bump version to 1.8.434 Co-Authored-By: Claude --- package.json | 2 +- src/environments/environment.prod.ts | 4 ++-- src/environments/environment.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index a00f8e2..e451f3c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "knowledgebuilder", - "version": "1.8.433", + "version": "1.8.434", "license": "MIT", "description": "An AI-powered web-based Learning app for English, Chinese, and Knowledge Bank part.", "author": { diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts index ea2ac9b..6f1d1bd 100644 --- a/src/environments/environment.prod.ts +++ b/src/environments/environment.prod.ts @@ -1,8 +1,8 @@ export const environment = { homeurl: 'https://www.alvachien.com/learning', production: true, - releasedate: '2026-08-23', - version: '1.8.433', + releasedate: '2026-08-25', + version: '1.8.434', apiUrl: 'https://www.alvachien.com/learningutil', pageTitle: 'Knowledge Builder', loginRequired: true, diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 60abc6e..7f33683 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -6,8 +6,8 @@ export const environment = { homeurl: 'http://localhost:29800', production: false, - releasedate: '2026-08-23', - version: '1.8.433', + releasedate: '2026-08-25', + version: '1.8.434', apiUrl: 'https://localhost:7135', pageTitle: 'Knowledge Builder', loginRequired: true, From 6fbd2b5ee3d7a0835ac16c8c95ab8991f112dddc Mon Sep 17 00:00:00 2001 From: Alva Chien Date: Sun, 30 Aug 2026 18:42:41 +0800 Subject: [PATCH 4/7] feat(filter): shared filter dialog across all list pages Complete the reusable-filter-dialog rollout (docs/reusable-filter-dialog-design.md): the dialog, the vocabulary adoption, and the Chinese/translate/knowledge migrations now live in the tree, replacing 7 page-local filter dialogs. - src/app/shared/filter-dialog: tree editor + pure model (schema-driven properties, seed/emit round-trip, validation, summaries); string operators now expose actslib's full matrix incl. lexicographic/Between - chinese: one filterDefinition + single Filter menu (was two dialogs/menus) - translate: same collapse onto SENTENCE_FILTER_PROPERTIES; adds the missing top-level 'english' i18n key the old dialog rendered raw - knowledge: KNOWLEDGE_FILTER_PROPERTIES with itemType as the enum proof (enumValues + choices labeled like the Type column) - rating Between added to all four pages' whitelists (legacy dialogs never offered it; the shared editor does; unrated = 0 keeps comparing) - retire RatingOperatorEnum/RatingCondition/matchRating/summarizeRatingFilter from ui-common (no hand-written matchers left) - deprecate page-local select-by-rating dialogs; decompose translate page into session/result components; i18n + docs updated to as-built Tests: 1826 passing across 71 spec files. Co-Authored-By: Claude Code --- CLAUDE.md | 4 +- docs/knowledge-chinese-pages-review.md | 173 ++ docs/reusable-filter-dialog-design.md | 598 ++++++ docs/vocabulary-exercises-architecture.md | 84 +- src/app/interfaces/index.ts | 2 + .../interfaces/knowledge-list-filter.spec.ts | 232 +++ src/app/interfaces/knowledge-list-filter.ts | 192 ++ src/app/interfaces/learnchinese.spec.ts | 181 ++ src/app/interfaces/learnchinese.ts | 135 ++ src/app/interfaces/questionbank-base.ts | 8 - src/app/interfaces/sentence.spec.ts | 262 +++ src/app/interfaces/sentence.ts | 308 ++++ src/app/interfaces/translate-data.ts | 1 - src/app/interfaces/ui-common.spec.ts | 28 - src/app/interfaces/ui-common.ts | 56 - src/app/interfaces/vocabulary.spec.ts | 380 ++-- src/app/interfaces/vocabulary.ts | 303 +-- .../chinese-exercises-list.component.html | 161 ++ .../chinese-exercises-list.component.scss | 82 + .../chinese-exercises-list.component.spec.ts | 217 +++ .../chinese-exercises-list.component.ts | 234 +++ ...nese-exercises-options-dialog.component.ts | 77 + .../chinese-exercises-options-dialog.html | 48 +- ...exercises-printoptions-dialog.component.ts | 103 ++ ...chinese-exercises-printoptions-dialog.html | 2 +- ...-exercises-select-dialog.component.spec.ts | 99 + ...inese-exercises-select-dialog.component.ts | 99 + .../chinese-exercises-select-dialog.html | 27 + ...inese-exercises-selectbyrating-dialog.html | 25 - .../chinese-exercises.component.html | 165 +- .../chinese-exercises.component.scss | 14 - .../chinese-exercises.component.spec.ts | 1191 +++++------- .../chinese-exercises.component.ts | 850 ++++----- .../english-listening.component.html | 12 +- .../formula-recites-printoptions-dialog.html | 4 +- .../formula-recites.component.html | 2 +- .../formula-recites.component.ts | 6 +- .../_knowledge-exercises-theme.scss | 8 +- .../knowledge-exercises-list/index.ts | 3 + .../knowledge-exercises-list.component.html | 381 ++-- .../knowledge-exercises-list.component.scss | 92 +- ...knowledge-exercises-list.component.spec.ts | 1623 ++--------------- .../knowledge-exercises-list.component.ts | 1047 ++--------- ...ises-printoptions-dialog.component.spec.ts | 133 ++ ...exercises-printoptions-dialog.component.ts | 101 + .../knowledge-exercises-printoptions-dlg.html | 2 +- ...-exercises-select-dialog.component.spec.ts | 130 ++ ...ledge-exercises-select-dialog.component.ts | 143 ++ .../knowledge-exercises-select-dialog.html | 33 + ...wledge-exercises-selectbycount-dialog.html | 17 - ...knowledge-exercises-selectbyid-dialog.html | 13 - ...ledge-exercises-selectbyrating-dialog.html | 25 - ...knowledge-exercises-selectfree-dialog.html | 17 - .../knowledge-exercises.component.html | 121 ++ .../knowledge-exercises.component.scss | 24 + .../knowledge-exercises.component.spec.ts | 778 ++++++++ .../knowledge-exercises.component.ts | 785 ++++++++ .../knowledge-exercises.routers.ts | 4 +- ...anslate-exercises-info-dialog.component.ts | 41 + .../translate-exercises-info-dialog.html | 2 +- ...ranslate-exercises-llm-dialog.component.ts | 93 + ...late-exercises-options-dialog.component.ts | 84 + .../translate-exercises-options-dialog.html | 7 +- ...exercises-printoptions-dialog.component.ts | 105 ++ ...anslate-exercises-printoptions-dialog.html | 4 +- ...slate-exercises-quiz-result.component.html | 30 + ...slate-exercises-quiz-result.component.scss | 26 + ...te-exercises-quiz-result.component.spec.ts | 85 + ...anslate-exercises-quiz-result.component.ts | 31 + ...late-exercises-quiz-session.component.html | 56 + ...late-exercises-quiz-session.component.scss | 102 ++ ...e-exercises-quiz-session.component.spec.ts | 112 ++ ...nslate-exercises-quiz-session.component.ts | 63 + ...slate-exercises-quiz-session.store.spec.ts | 249 +++ .../translate-exercises-quiz-session.store.ts | 169 ++ ...-exercises-quizoptions-dialog.component.ts | 76 + ...ranslate-exercises-quizoptions-dialog.html | 14 + ...te-exercises-review-session.component.html | 60 + ...te-exercises-review-session.component.scss | 54 + ...exercises-review-session.component.spec.ts | 83 + ...late-exercises-review-session.component.ts | 162 ++ ...ate-exercises-review-session.store.spec.ts | 612 +++++++ ...ranslate-exercises-review-session.store.ts | 333 ++++ ...xercises-reviewoptions-dialog.component.ts | 79 + ...nslate-exercises-reviewoptions-dialog.html | 17 + ...slate-exercises-select-dialog.component.ts | 99 + .../translate-exercises-select-dialog.html | 27 + ...slate-exercises-selectbyrating-dialog.html | 25 - ...ate-exercises-sentence-list.component.html | 194 ++ ...ate-exercises-sentence-list.component.scss | 88 + ...-exercises-sentence-list.component.spec.ts | 200 ++ ...slate-exercises-sentence-list.component.ts | 231 +++ ...ate-exercises-typing-result.component.html | 47 + ...ate-exercises-typing-result.component.scss | 15 + ...-exercises-typing-result.component.spec.ts | 69 + ...slate-exercises-typing-result.component.ts | 32 + ...te-exercises-typing-session.component.html | 34 + ...te-exercises-typing-session.component.scss | 28 + ...exercises-typing-session.component.spec.ts | 64 + ...late-exercises-typing-session.component.ts | 39 + ...ate-exercises-typing-session.store.spec.ts | 134 ++ ...ranslate-exercises-typing-session.store.ts | 124 ++ .../translate-exercises.component.html | 313 +--- .../translate-exercises.component.scss | 18 +- .../translate-exercises.component.spec.ts | 1565 ++++++++++------ .../translate-exercises.component.ts | 1281 ++++++------- src/app/pages/vocabulary-exercises/index.ts | 2 - ...-exercises-dictation-result.component.html | 2 +- ...exercises-dictation-session.component.html | 4 +- ...abulary-exercises-quit-confirm-dialog.html | 2 +- ...ulary-exercises-quiz-result.component.html | 6 +- ...lary-exercises-quiz-session.component.html | 14 +- ...y-exercises-quiz-session.component.spec.ts | 4 +- ...vocabulary-exercises-quiz-session.store.ts | 4 +- ...cabulary-exercises-quizoptions-dialog.html | 2 +- ...rcises-rating-filter-dialog.component.html | 35 - ...rcises-rating-filter-dialog.component.scss | 14 - ...ses-rating-filter-dialog.component.spec.ts | 92 - ...xercises-rating-filter-dialog.component.ts | 78 - ...ry-exercises-review-session.component.html | 16 +- ...ary-exercises-review-session.store.spec.ts | 87 +- ...cabulary-exercises-review-session.store.ts | 29 +- ...bulary-exercises-reviewoptions-dialog.html | 4 +- ...ulary-exercises-select-dialog.component.ts | 6 +- ...y-exercises-spelling-result.component.html | 4 +- ...-exercises-spelling-session.component.html | 6 +- ...ercises-spelling-session.component.spec.ts | 28 +- ...ry-exercises-spelling-session.component.ts | 21 +- ...lary-exercises-spellingoptions-dialog.html | 2 +- ...xercises-word-filter-dialog.component.html | 35 - ...xercises-word-filter-dialog.component.scss | 10 - ...cises-word-filter-dialog.component.spec.ts | 122 -- ...-exercises-word-filter-dialog.component.ts | 89 - ...abulary-exercises-word-list.component.html | 52 +- ...abulary-exercises-word-list.component.scss | 4 - ...lary-exercises-word-list.component.spec.ts | 152 +- ...ocabulary-exercises-word-list.component.ts | 102 +- ...ary-exercises-worksheetoptions-dialog.html | 2 +- .../vocabulary-exercises.component.html | 9 +- .../vocabulary-exercises.component.spec.ts | 402 +++- .../vocabulary-exercises.component.ts | 257 ++- .../filter-dialog/filter-dialog-model.spec.ts | 581 ++++++ .../filter-dialog/filter-dialog-model.ts | 816 +++++++++ .../filter-dialog.component.html | 165 ++ .../filter-dialog.component.scss | 220 +++ .../filter-dialog.component.spec.ts | 332 ++++ .../filter-dialog/filter-dialog.component.ts | 458 +++++ src/app/shared/filter-dialog/index.ts | 6 + src/app/shared/utils/shuffle.spec.ts | 34 + src/app/shared/utils/shuffle.ts | 28 +- src/assets/data/i18n/en.json | 283 +-- src/assets/data/i18n/zh-CN.json | 281 +-- src/styles/_shared-tables.scss | 23 +- 153 files changed, 16944 insertions(+), 7202 deletions(-) create mode 100644 docs/knowledge-chinese-pages-review.md create mode 100644 docs/reusable-filter-dialog-design.md create mode 100644 src/app/interfaces/knowledge-list-filter.spec.ts create mode 100644 src/app/interfaces/knowledge-list-filter.ts create mode 100644 src/app/interfaces/sentence.spec.ts create mode 100644 src/app/interfaces/sentence.ts delete mode 100644 src/app/interfaces/ui-common.spec.ts create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-list.component.html create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-list.component.scss create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-list.component.spec.ts create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-list.component.ts create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-options-dialog.component.ts create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-printoptions-dialog.component.ts create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-select-dialog.component.spec.ts create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-select-dialog.component.ts create mode 100644 src/app/pages/chinese-exercises/chinese-exercises-select-dialog.html delete mode 100644 src/app/pages/chinese-exercises/chinese-exercises-selectbyrating-dialog.html create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-printoptions-dialog.component.spec.ts create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-printoptions-dialog.component.ts create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-select-dialog.component.spec.ts create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-select-dialog.component.ts create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-select-dialog.html delete mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-selectbycount-dialog.html delete mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-selectbyid-dialog.html delete mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-selectbyrating-dialog.html delete mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises-selectfree-dialog.html create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises.component.html create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises.component.scss create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises.component.spec.ts create mode 100644 src/app/pages/knowledge-exercises/knowledge-exercises-list/knowledge-exercises.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-info-dialog.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-llm-dialog.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-options-dialog.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-printoptions-dialog.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-result.component.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-result.component.scss create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-result.component.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-result.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-session.component.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-session.component.scss create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-session.component.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-session.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-session.store.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-quiz-session.store.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-quizoptions-dialog.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-quizoptions-dialog.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-review-session.component.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-review-session.component.scss create mode 100644 src/app/pages/translate-exercises/translate-exercises-review-session.component.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-review-session.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-review-session.store.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-review-session.store.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-reviewoptions-dialog.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-reviewoptions-dialog.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-select-dialog.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-select-dialog.html delete mode 100644 src/app/pages/translate-exercises/translate-exercises-selectbyrating-dialog.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-sentence-list.component.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-sentence-list.component.scss create mode 100644 src/app/pages/translate-exercises/translate-exercises-sentence-list.component.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-sentence-list.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-result.component.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-result.component.scss create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-result.component.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-result.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-session.component.html create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-session.component.scss create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-session.component.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-session.component.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-session.store.spec.ts create mode 100644 src/app/pages/translate-exercises/translate-exercises-typing-session.store.ts delete mode 100644 src/app/pages/vocabulary-exercises/vocabulary-exercises-rating-filter-dialog.component.html delete mode 100644 src/app/pages/vocabulary-exercises/vocabulary-exercises-rating-filter-dialog.component.scss delete mode 100644 src/app/pages/vocabulary-exercises/vocabulary-exercises-rating-filter-dialog.component.spec.ts delete mode 100644 src/app/pages/vocabulary-exercises/vocabulary-exercises-rating-filter-dialog.component.ts delete mode 100644 src/app/pages/vocabulary-exercises/vocabulary-exercises-word-filter-dialog.component.html delete mode 100644 src/app/pages/vocabulary-exercises/vocabulary-exercises-word-filter-dialog.component.scss delete mode 100644 src/app/pages/vocabulary-exercises/vocabulary-exercises-word-filter-dialog.component.spec.ts delete mode 100644 src/app/pages/vocabulary-exercises/vocabulary-exercises-word-filter-dialog.component.ts create mode 100644 src/app/shared/filter-dialog/filter-dialog-model.spec.ts create mode 100644 src/app/shared/filter-dialog/filter-dialog-model.ts create mode 100644 src/app/shared/filter-dialog/filter-dialog.component.html create mode 100644 src/app/shared/filter-dialog/filter-dialog.component.scss create mode 100644 src/app/shared/filter-dialog/filter-dialog.component.spec.ts create mode 100644 src/app/shared/filter-dialog/filter-dialog.component.ts create mode 100644 src/app/shared/filter-dialog/index.ts create mode 100644 src/app/shared/utils/shuffle.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index d69bbaf..458045c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,8 +87,8 @@ Decomposed into a container component, presentational children, and three signal - **`VocabularyExercisesWordListComponent`** - the presentational list screen. Receives the container's shared dataSource/selection/signals as inputs and forwards user intents as outputs; wires its template's paginator/sort onto the shared dataSource via `@ViewChild` setters. - **Signal stores** - `ReviewSessionStore` (queue, cursor, auto-mode interval, per-word `ratingMap`, progress computeds), `SpellingSessionStore` (queue, per-letter reveal state, results), and `QuizSessionStore` (single-choice question queue, per-question answer state, results). Plain `@Injectable()` classes (no state library), provided in the container's `providers` so the container and session screens share one instance per page instance. They are imported directly, not via the barrel. - **Session screens** - `review-session`, `spelling-session`, `spelling-result`, `quiz-session`, and `quiz-result` components inject their store directly (no state inputs) and each registers a `document:keyup` `@HostListener`, so keystroke handling only lives while that screen is mounted. -- **Dialogs** - one mode-driven select dialog (By Count / Free Selection / By Word), plus `reviewoptions`, `spellingoptions`, `worksheetoptions`, `quizoptions`, `word-filter`, and `rating-filter` dialogs. Filter dialogs clone their seed conditions so Cancel cannot mutate them; options dialogs round-trip `currentSettings` so reopening shows the last picks. -- **Filter pipeline** - free-text + word conditions + rating conditions combine into a `VocabularyListFilter` (`interfaces/vocabulary.ts`), serialized as JSON into `MatTableDataSource.filter`; the row predicate delegates to the pure `matchVocabularyListFilter()` (closing over the rating map). After async rating loads, re-assign `dataSource.filter = dataSource.filter` to force re-filtering. +- **Dialogs** - one mode-driven select dialog (By Count / Free Selection / By Word), plus `reviewoptions`, `spellingoptions`, `worksheetoptions`, `quizoptions`, and the **shared** `filter` dialog (`src/app/shared/filter-dialog/` — `SharedFilterDialogComponent`, design in `docs/reusable-filter-dialog-design.md`), opened with the page's `VOCABULARY_FILTER_PROPERTIES` schema and an actslib `IFilterDefinition` seed; Submit returns the edited definition, Cancel/backdrop/Esc return `undefined`. It is a project-wide component (all four list pages — vocabulary, knowledge, Chinese and translate — now filter through it, each with its own `*_FILTER_PROPERTIES` schema), not a vocabulary-page file. Options dialogs round-trip `currentSettings` so reopening shows the last picks. +- **Filter pipeline** - free-text + the actslib **`IFilterDefinition`** (word/rating conditions in AND/OR-joined, nestable groups, produced directly by the shared dialog — no page-side tree model or translation step) combine into a `VocabularyListFilter` (`interfaces/vocabulary.ts`), serialized as JSON into `MatTableDataSource.filter`; the row predicate delegates to the pure `matchVocabularyListFilter()` (closing over the rating map), which runs `FilterUtility.MatchFilter` against a case-folded target `{ enword, cnword, rating }` (string condition values are folded by each property's `prepareValue` at dialog emit time; free-text stays a hand-written cross-field check). After async rating loads, re-assign `dataSource.filter = dataSource.filter` to force re-filtering. - **Ratings** - list ratings live in a `contentRatingMap` signal; the review store captures only server-confirmed ratings and returns them on `quit()`, which the container merges back into the map. Temporary (uploaded) content gets a synthetic `LearningContent` with a negative id, and rating calls are disabled for it. - **Quiz exercise** - the Exercises-menu Quiz item reuses the shared word-queue selection (table selection, else filter + `prepareWordQueue`), then builds single-choice questions via the pure `buildVocabularyQuizQuestions()` (`interfaces/vocabulary.ts`): EN word -> pick the CN explanation among 4 candidates (`en2cn`), or CN explanation -> pick the EN word (`cn2en`). Distractors come from the visible (filtered) rows, deduplicated by displayed text; questions that cannot gather one distinct alternative are skipped. diff --git a/docs/knowledge-chinese-pages-review.md b/docs/knowledge-chinese-pages-review.md new file mode 100644 index 0000000..b295289 --- /dev/null +++ b/docs/knowledge-chinese-pages-review.md @@ -0,0 +1,173 @@ +# Knowledge Exercises & Chinese Exercises Pages — Logic Review + +- **Date:** 2026-08-29 +- **Branch:** `feat/rating-unrated-semantics` +- **Scope:** `src/app/pages/knowledge-exercises/**`, `src/app/pages/chinese-exercises/**`, and the + filter pipeline they share (`src/app/interfaces/knowledge-list-filter.ts`, `learnchinese.ts`, + `ui-common.ts` `matchRating`, `vocabulary.ts` `RatingCondition`). +- **Method:** manual read of containers, list children, dialogs, converters; verified against the + installed `@angular/material` 21 source and real content data + (`knowledgebuilder-content/learnchinese/`); focused tests run via `ng test` (158 tests, 5 spec + files, all passing at review time). +- **Re-verified:** 2026-08-29, after the knowledge filter-UI merge (Content/Rating dialogs → one + field-selecting filter dialog; filter fields aligned to table columns). Findings and line refs + refreshed against the current tree; full suite green (1808 tests / 75 spec files) and the + production build passes. +- **Status legend:** each finding carries a status (`open` / `fixed` / `wontfix`) for tracing. + +--- + +## Findings (real issues) + +### 1. [Chinese] Recite flow is a dead end — status: open + +- **Where:** `chinese-exercises.component.ts:590` (`onStart() {}`) +- **What:** Exercises ▾ → Recite opens `ChineseExercisesOptionsDialogComponent`, collects + level/count/allowEmptyAnswer, then calls `onStart()` — an empty stub. Nothing happens. + `this.setting` (`ChineseReciteOption`) is write-only state. +- **Origin:** pre-existing on `main` (the recite screen was removed earlier); the menu item and + dialog survived it. +- **Suggestion:** remove the Recite menu item + options dialog + `setting`, or rewire the flow to + an actual target. + +### 2. [Chinese] Print options collected but silently ignored — status: open + +- **Where:** `chinese-exercises.component.ts:613-619` (copying dialog results) vs + `chinese-exercises.component.ts:658-667` (`onPrint` building `execPrintSetting`). +- **What:** `onPrintWithOptions` stores `respectRetentionCurve`, `printExecDate`, `execDate`, + `printEntryDate` into `printSetting`, but `onPrint` builds a fresh `execPrintSetting` that + hardcodes `printEntryDate: true` and never reads the other three — + `KnowledgeExercisePrintOption` (`questionbank-base.ts:1374`) has no such fields. The dialog's + exec-date radio ("respect retention curve") has no effect. +- **Origin:** pre-existing on `main`. +- **Contrast:** the knowledge print dialog is fine — every option flows through + `this.printSetting` into `uiService.setSelectedExerciseItem`. +- **Suggestion:** either extend `KnowledgeExercisePrintOption` and implement the retention/exec-date + behavior in the display/print renderer, or drop the dead controls from + `chinese-exercises-printoptions-dialog.html`. + +### 3. [Chinese] v2 multi-segment items print as "undefined" — status: open + +- **Where:** `learnchinese.ts:277-278` (`convertChineseReciteItemToKnowledge`, FillInTheBlank + branch): `qitem.question += item.content`. +- **What:** multi-segment items (`contentlength > 0`, no `content` field) produce + `question = ", . undefined"` and no answers. The Dictation branch loops + content1..content39; the FillInTheBlank branch does not. +- **Data evidence:** 10 items in `knowledgebuilder-content/learnchinese/gaozhongchangshi.json` + (a version-2 file) have `contentlength` and no `content`; printing that file without a narrow + selection includes them. +- **Origin:** pre-existing (the converter is unchanged on this branch), data-verified during this + review. +- **Suggestion:** in the FillInTheBlank branch, fall back to the joined segments (same logic as + `getChineseReciteItemDisplayContent`) when `content` is absent. + +### 4. [Chinese] Filter cannot see multi-segment content — status: open + +- **Where:** `learnchinese.ts:90-91` (`chineseConditionFieldText`, `content` case) and + `learnchinese.ts:110-111` (free-text haystack) — both read `item.content` only. +- **What:** the list column renders joined content1..N segments + (`getChineseReciteItemDisplayContent`), so users can see text that free text and the Content + condition cannot match. Affects the same 10 multi-segment items as finding 3. +- **Origin:** introduced by this branch (the filter is new). +- **Suggestion:** build the searchable content from the same joined-segments helper inside + `matchChineseListFilter`. + +### 5. [Both] Race: old file's rows can be rated under the new file's contentId — status: fixed (knowledge, 2026-08-29) / open (Chinese) + +- **Fix (knowledge):** `studyContentId` is now reset to 0 on file switch and only assigned in the + content `next` handler (after the token check), so clicks on the still-visible old rows hit the + `studyContentId <= 0` guard and are dropped; `getRatings` uses the local `contentId`. Regression + test: "should ignore rating clicks on stale rows while the new file is loading". + +- **Where:** `chinese-exercises.component.ts:340`, `knowledge-exercises.component.ts:361` + (`this.studyContentId = selectedContent.id` before content resolves). +- **What:** on file switch, `studyContentId` is assigned immediately while the previous file's rows + stay visible until the new content's `next`/`error`. A rating click in that window upserts + `(newContentId, oldItemId)` — persisting a rating into the wrong file's namespace. The error path + clears rows but cannot recall the already-issued request. +- **Probability:** narrow window (needs a slow content load plus a user click), but it is exactly + the failure class the error-path comments guard against. +- **Suggestion:** keep `studyContentId = 0` until the content `next` arrives (or disable the rating + toggles while a load is in flight). + +### 6. [Both] Race: slow `getRatings` response can revert a fresh rating — status: fixed (knowledge, 2026-08-29) / open (Chinese) + +- **Fix (knowledge):** the `next` handler overlays both the current `contentRatingMap` (covers + already-saved clicks) and `pendingContentRatings` (covers in-flight clicks) on top of the fetched + server list before `contentRatingMap.set`. Regression test: "should keep locally applied ratings + when a stale getRatings response lands". + +- **Where:** `chinese-exercises.component.ts:389-395`, `knowledge-exercises.component.ts:408-425` + (the `getRatings` `next` handler rebuilding `contentRatingMap`). +- **What:** if the initial `getRatings` fetch lands *after* an early `upsertRating` succeeded, the + map is rebuilt from the stale server list without the just-saved rating; + `pendingContentRatings` was already deleted on the upsert's success, so nothing re-applies it and + the toggle visually reverts to the stale server value. +- **Suggestion:** in the `next` handler, re-apply any `pendingContentRatings` entries on top of the + fetched map before `contentRatingMap.set`. + +## Minor findings + +### 7. [Knowledge] Answer panel persists across prev/next — status: fixed (2026-08-29) + +- **Fix:** `onPreviousItem`/`onNextItem` now also reset `showDetailAnswer`, matching the hint-flag + handling. Regression test: "onNextItem/onPreviousItem should hide the previous item's answer + panel". + +`onPreviousItem`/`onNextItem` (`knowledge-exercises.component.ts:729-747`) reset the hint flags and +markdown but not `showDetailAnswer`, so the next item's answer is shown without pressing Toggle +Answer. Possibly intentional for studying; inconsistent with the hint handling either way. + +### 8. [Knowledge] `onPreviewCore` mutates cached source rows — status: fixed (2026-08-29) + +- **Fix:** the print queue is now built from shallow copies (`{ ...item, order }`, sub-items + copied too); the `LearningContentService`-cached row objects are no longer touched. Regression + test: "should renumber copies and leave the cached source rows untouched". + +`knowledge-exercises.component.ts:632-653` renumbers `item.order` on the row objects from +`dataSource.data`, which are the `LearningContentService` per-fileUrl cached JSON objects — the +mutation persists in the cache across visits. The Chinese flow correctly renumbers freshly +converted copies (`convertChineseReciteItemToKnowledge` output) instead. + +### 9. [Both] IME composition vs live filtering — status: fixed (knowledge, 2026-08-29) / open (Chinese) + +- **Fix (knowledge):** the free-text input moved from `(keyup)` to `(input)` plus a + `compositionstart`/`compositionend` guard — pinyin fragments are suppressed and the final text is + emitted on compositionend (value read from the event target, no ngModel race). Regression test: + "should suppress live filtering during IME composition" (list spec). + +Free text applies on `(keyup)`; typing Chinese via an IME fires filtering for intermediate pinyin +fragments. Composition-aware filtering (compositionstart/end guard or `(input)`-based) would be +smoother — notable on a Chinese-content page. + +### 10. [Both] List state resets when returning from detail — status: fixed (knowledge, 2026-08-29) / open (Chinese) + +- **Fix (knowledge):** the list screen is no longer destroyed by the mode switch — the container + keeps it mounted and toggles it with `[hidden]`, rendering detail/extrainfo via `@if`. Paginator + page/size and sort state (and scroll position) now survive a detail round-trip for free; the + `appliedFreeText`/`selectionCount` seeds remain but only matter at first construction. + Regression test: "should keep the list screen mounted (hidden) during detail visits". + +Entering detail/extra-info destroys the list child (`@switch` in +`knowledge-exercises.component.html`); returning re-creates paginator (page 0) and sort (none). +Free text and selection survive by design (`appliedFreeText` seed + `selectionCount` seed), but +page/sort do not. Cosmetic. + +## Verified non-issues (checked and found correct) + +- `dataSource.filter = this.dataSource.filter` self-assignment re-filter trick: safe on Material 21 + — the setter publishes to a `BehaviorSubject` unconditionally (verified in + `@angular/material/fesm2022/table.mjs`). +- Select dialogs (both pages): count/offset clamped as defense-in-depth; By-ID no-match paste can + neither close the dialog nor wipe the existing selection. +- Dialog Cancel semantics: seed conditions cloned; `undefined` (Cancel/backdrop/Esc) leaves state + untouched. +- Rating toggle deselect-restore (`event.value < 1` → restore group value) and stale-upsert + dropping via `pendingContentRatings` are sound. +- Unrated-as-0 semantics: `matchRating` compares numerically; `< 1` matches unrated, `>= 1` matches + rated — consistent with the branch's design (`ui-common.ts`). +- Knowledge Print button disabled on empty selection; detail prev/next buttons bounds-disabled. +- Filter/selection survive list-child re-creation (free-text reseed, `selectionCount` seed). +- Focused tests passing at review time: `ng test` — 158 tests / 5 spec files + (both page containers, `learnchinese.spec.ts`, `knowledge-list-filter.spec.ts`, + `ui-common.spec.ts`). diff --git a/docs/reusable-filter-dialog-design.md b/docs/reusable-filter-dialog-design.md new file mode 100644 index 0000000..2b489b7 --- /dev/null +++ b/docs/reusable-filter-dialog-design.md @@ -0,0 +1,598 @@ +# Design: Shared Filter Dialog (reusable condition-tree filter editor) + +Status: **implemented; vocabulary page migrated (Phase 1 + 2)** — the shared +dialog lives in `src/app/shared/filter-dialog/` and the vocabulary page uses it +(`VOCABULARY_FILTER_PROPERTIES` schema in `interfaces/vocabulary.ts`); the old +`vocabulary-exercises-filter-dialog.*` files and the `VocabularyFilterGroup` +model are deleted. Phases 3–5 (knowledge, Chinese, translate adoption) are +pending. Implementation deltas from this doc, all deliberate: +- the leaf holds **three value slots** (`single` / `between` / `choices`) + rather than the five named fields of §6.1; +- the date editor is a native `` (no `matDatepicker`, so the + dialog needs no date-adapter providers); +- summaries render **comparison symbols** (`>=`, `<`) for numeric/date + properties and word labels otherwise, keeping rating phrases compact; +- the join word in summaries is **translated** (`common.joinAnd`/`joinOr`) + rather than hardcoded English, matching the localization-first rule. + +Author: Claude Code session 2026-08-30 +Base implementation: the (now deleted) vocabulary filter dialog, which this +component generalizes and replaced. + +--- + +## 1. Purpose & scope + +One project-wide dialog for defining list-page filters as a **condition tree** +(SQL-WHERE shape: leaves = property conditions, inner nodes = AND/OR joins), +covering all six exercise pages. The dialog is configured per page with a +**property schema** whose operator set derives from actslib's filter semantics; +its seed and result are actslib-native (`IFilterDefinition`), so pages store, +translate-free, and evaluate exactly what the dialog returns. + +The name "Filter Dialog" (not "filter *options* dialog") avoids a collision +with the existing options dialogs (`reviewoptions`, `spellingoptions`, …), +which configure exercise options, not filters. + +### Goals + +1. **actslib-driven operators.** Each property's allowed operations are derived + from actslib (`FilterOperation` + the per-kind support matrix) and narrowed + by a per-page whitelist. Two special editor cases per the brief: + - **enum properties** render their value editor as a **multiple-choice list** + (checkboxes), compiled to actslib conditions on Submit (§7.3); + - **`Between`** renders **two inputs** (low + high) (§7.2). +2. **mat-tree + detail-pane design**, matching the vocabulary dialog: tree + navigator (left), editor for the selected node (right), draggable splitter, + insert/delete toolbar, live expression preview, Submit gated by validation. +3. **Replace, then unify**: first adopter is the vocabulary page (replacing + `VocabularyFilterGroup` + its bespoke dialog); the knowledge, Chinese and + translation pages' five legacy filter dialogs follow (§12). + +### Non-goals + +- The **free-text search box** stays on each page's filter bar (hand-written + cross-field matching; never enters the dialog). +- **Where values come from** (row fields vs. the user's rating from + `contentRatingMap`) is the page's concern — pages keep evaluating against a + synthesized target. +- No negation / NOT groups: actslib `FilterUtility` cannot express them. +- No persistence of filter presets (in-memory per page, as today). + +--- + +## 2. Current state (what gets replaced) + +| Page | Dialog today | Model today | Notes | +|---|---|---|---| +| vocabulary | `vocabulary-exercises-filter-dialog` | tree (`VocabularyFilterGroup`), edited via bespoke node/row copies | **reference design**; tree + panel + validation already built | +| knowledge-exercises | `knowledge-exercises-filter-dialog` | flat rows (`KnowledgeCondition[]` + `RatingCondition[]`), AND-only | **migrated** (shared dialog over `KNOWLEDGE_FILTER_PROPERTIES`; the `itemType` enum multi-select is §7.3's proof) | +| chinese-exercises | `chinese-exercises-content-filter-dialog` + `-rating-filter-dialog` | flat condition lists | two dialogs → one — **migrated** (shared dialog over `CHINESE_FILTER_PROPERTIES`) | +| translate-exercises | `translate-exercises-word-filter-dialog` + `-rating-filter-dialog` | flat `SentenceCondition[]` + `RatingCondition[]` | two dialogs → one — **migrated** (shared dialog over `SENTENCE_FILTER_PROPERTIES`) | + +Common shape: each page hardcodes its field union (`'enword' | 'cnword' | +'rating'`…), its operator list, its row editor template, and a translation +function to actslib. All of that becomes the **property schema** input; the +dialog owns tree editing, validation, and the `IFilterDefinition` I/O. + +--- + +## 3. Design decisions at a glance + +| # | Decision | Rationale | +|---|---|---| +| D1 | Seed + result are **actslib `IFilterDefinition`** | Pages already evaluate it (`FilterUtility.MatchFilter`); kills every page-specific dialog model and the `VocabularyFilterGroup` ↔ definition translation. The dialog is generic precisely because its I/O is the evaluator's language. | +| D2 | **Property schema** passed via `MAT_DIALOG_DATA`, operators defaulted per kind from actslib's matrix, narrowed by whitelist | "allowed options per property" without every page re-listing `>`/`>=`/… ; whitelist still controls what's *offered* (e.g. rating offers only `=`). | +| D3 | Enum multi-select compiles to **one leaf that emits an OR-of-`Equal` group**; seeds fold back | actslib has no `In` operation; OR-of-equals is the only faithful encoding, and `enumValues` per condition keeps actslib's enum validation. Fold-back keeps round-trips editable (§7.3). | +| D4 | Valueless custom operators via **`customOperators` hook** (`emit` + `recognize`) | Vocabulary's `isPhrase` (→ `Contains ' '`) is app semantics actslib can't express; the hook keeps the dialog reusable without hardcoding word knowledge (§7.4). | +| D5 | Keep the vocabulary editor-state pattern: numeric-id nodes, **reference `trackBy`**, **id `expansionKey`**, all edits **immutable through the root signal** | These are load-bearing CDK facts, not style choices (see §6.3 and the bug history); encoding them in the shared component prevents re-introducing them page by page. | +| D6 | `prepareValue?` hook per property for case-folding / trimming | actslib string comparison is case-sensitive; the vocabulary page lowercases folded values *and* folded row fields. Keeping the hook on the property lets the page decide match semantics while the dialog stays content-agnostic (§8.4). | +| D7 | Validation rules move into the dialog model, same contract as today: blank/missing values and non-branching nested groups block Submit; root exempt | Just implemented in the vocabulary dialog; promoted verbatim to shared code (§9). | + +--- + +## 4. Component overview + +``` +src/app/shared/filter-dialog/ +├── index.ts # public surface barrel +├── filter-dialog.component.ts # dialog shell (MAT_DIALOG_DATA consumer) +├── filter-dialog.component.html # mat-tree + splitter + detail pane +├── filter-dialog.component.scss # copied from the vocabulary dialog +├── filter-dialog.component.spec.ts # DOM tests +├── filter-dialog-model.ts # editor types + ALL pure logic (seed, +│ # mutate, validation, emit, summarize) +└── filter-dialog-model.spec.ts # pure-function tests (no Angular) +``` + +- `SharedFilterDialogComponent`, selector `app-filter-dlg`, standalone, + `OnPush`, template/SCSS imported per project conventions. +- **All tree logic lives in `filter-dialog-model.ts` as pure functions over + plain objects** (the vocabulary component's private methods promoted to + module functions taking explicit args). The component is then thin: hold the + `root`/`selectedId` signals, call model functions, wire the template. This + makes the interesting logic testable without `TestBed` and reusable for a + future flat-mode variant. +- Imports: `MatTree`/`MatNestedTreeNode`/`MatTreeNodeDef`/`MatTreeNodeOutlet`, + `FormsModule`, Material form fields/select/checkbox-list, `TranslocoModule`. +- Naming: types are `Shared…` (prefix `FilterDialog…`) to avoid clashing with + the per-page `KnowledgeFilterDialogRow` etc. during the migration window. + +## 5. Public contract + +```ts +import type { EnumLike, FilterOperation } from 'actslib'; +import type { IFilterCondition, IFilterDefinition } from 'actslib'; + +/** What kind of values a property carries. Drives the default operator list + * (§5.2), the value editor (§7), and the seed/emit dispatch. */ +export type FilterPropertyKind = 'string' | 'number' | 'date' | 'enum'; + +/** One choice of an enum property's multiple-choice editor. */ +export interface FilterEnumChoice { + value: string | number; + labelKey: string; // i18n key, translated by the dialog +} + +/** A valueless, app-specific operator (e.g. vocabulary 'isPhrase'). The page + * supplies the actslib encoding and the fold-back recognizer. */ +export interface FilterCustomOperator { + /** editor-local id, never crosses the dialog boundary */ + id: string; + labelKey: string; + /** actslib condition this operator emits on Submit */ + emit(property: string): IFilterCondition; + /** true when `condition` is one of this operator's emissions (seed fold-back) */ + recognize(condition: IFilterCondition): boolean; +} + +/** One filterable property of the page's target shape. */ +export interface FilterableProperty { + /** actslib condition property name (matched against the evaluated target) */ + key: string; + labelKey: string; + kind: FilterPropertyKind; + /** offered operators; default = per-kind actslib set (§5.2), ∩ when given. */ + operations?: FilterOperation[]; + /** kind 'enum': actslib enum validation, passed through to each condition. */ + enumValues?: EnumLike; + /** kind 'enum': choices rendered as the multiple-value editor (§7.3). */ + choices?: FilterEnumChoice[]; + /** valueless operators appended to the operator select (§7.4). */ + customOperators?: FilterCustomOperator[]; + /** number/date editors: input constraints (ui only; not enforced on text input). */ + numberRange?: { min?: number; max?: number }; + /** transforms the raw editor value before it is emitted (D6: trim + lowercase). */ + prepareValue?: (value: string | number) => string | number; +} + +export interface FilterDialogData { + properties: FilterableProperty[]; + /** seed = the filter currently in effect; empty/undefined starts blank */ + root?: IFilterDefinition; + /** deepest group level the toolbar offers; default 4 */ + maxDepth?: number; + /** dialog title key; default 'common.editFilter' */ + titleKey?: string; +} + +export interface FilterDialogResult { + root: IFilterDefinition; +} +``` + +The **uniform close contract is unchanged**: Submit → `{ root }`; +Cancel/backdrop/Esc → `undefined` (caller leaves state untouched). + +### 5.1 Operator derivation from actslib + +actslib's support matrix (FilterUtility docs + `MatchCondition`): + +| kind | default operators (actslib order) | +|---|---| +| `string` | `BeginsWith`, `Contains`, `Equal`, `EndsWith`, `>` `>=` `<` `<=` (lexicographic), `Between` | +| `number` | `>`, `>=`, `=`, `<=`, `<`, `Between` | +| `date` | same as `number` (actslib detects dates at runtime) | +| `enum` | `Equal` only at the *leaf* level (multi-choice compiles to OR-of-`Equal`; §7.3) | + +Rules: + +- The dialog offers `customOperators` **in addition** to the (whitelist-narrowed) + default list — `hasValue: false` by definition (they encode the value). +- Ordering follows the table above (familiar → exotic); pages that care pass + an explicit `operations` list, which also fixes order. +- A property whose effective operator list is empty is a schema bug: dev-mode + `console.warn`, property skipped in the select. + +### 5.2 Per-page schema examples + +```ts +// vocabulary page (the migration's first target) +const VOCABULARY_FILTER_PROPERTIES: FilterableProperty[] = [ + { key: 'enword', labelKey: 'vocabularyExercises.word', kind: 'string', + operations: [BeginsWith, Contains, Equal, EndsWith], + customOperators: [IS_PHRASE], // emit: Contains ' '; recognize: op=Contains && lowValue=' ' + prepareValue: v => String(v).trim().toLowerCase() }, + { key: 'cnword', labelKey: 'chinese', kind: 'string', + operations: [BeginsWith, Contains, Equal, EndsWith], + prepareValue: v => String(v).trim().toLowerCase() }, + { key: 'rating', labelKey: 'rating', kind: 'number', + operations: [GreaterThan, LargerOrEquals /* >= */, Equal, LessOrEquals, LessThan], + numberRange: { min: 0, max: 5 } }, +]; +``` + +(The rating property replaces today's hand-mapped `RatingOperatorEnum`: the +dialog works in actslib ops directly; `matchRating` stays for legacy paths +until phase 4.) + +## 6. Editor state model + +### 6.1 Types + +```ts +/** One editable leaf. All value kinds COEXIST (today's VocabularyFilterDialogRow + * pattern): switching property/operator never loses input, and the template + * only shows the controls the current dispatch selects. */ +export interface SharedFilterDialogLeaf { + id: number; + propertyKey: string; + /** a FilterOperation value, or a customOperator id */ + operator: string; + textValue: string; // string editor + numberValue: number | null; // number/date single-value editor + lowValue: number | null; // Between bounds + highValue: number | null; + selectedChoices: (string | number)[]; // enum editor (§7.3) +} + +export interface SharedFilterDialogNode { + id: number; + join: FilterJoinType; + members: Array; +} +``` + +### 6.2 Pure functions in `filter-dialog-model.ts` + +| function | role | +|---|---| +| `seedTree(def: IFilterDefinition \| undefined, schema): SharedFilterDialogNode` | copy-in: conditions → leaves (fold-back: custom `recognize`, Between, enum OR-of-equals → one multi-choice leaf, single value); nested groups → nodes; **structure preserved at any depth**; never mutates the caller's def | +| `emitTree(root, schema): IFilterDefinition` | Submit output: leaves → conditions/groups (§7 dispatch); drops nothing (validation already guarantees completeness); root may emit `conditions: []` (= match-all = cleared filter) | +| `insertMember / deleteMember / patchNode / patchLeaf` | the vocabulary `mutateNode`/`replaceRow` immutables, generalized: every edit returns a new object along the mutation path | +| `emptyLeaf(schema): SharedFilterDialogLeaf` | new row = first property, its first operator, blank values | +| `validateTree(root, schema): ValidationState` | `hasMissingValue` + `invalidGroupIds` (§9) | +| `summarizeFilterDefinition(def, schema, labels): string` | preview + menu label (parenthesized notation, per-group join, choice lists as `a/b/c`, Between as `low ≤ x ≤ high`); mirrors today's `summarizeVocabularyFilterTree` | + +`patchLeaf` replaces a leaf by id inside its parent (same `parentIdOf` + +`members.map` trick as the vocabulary dialog). + +### 6.3 The CDK tree invariants (load-bearing — do not "simplify") + +Proven twice in the vocabulary dialog's bug history; the shared component must +carry them forward verbatim: + +1. **`[trackBy]` = object reference** (`(_i, m) => m`). CdkTree's nested nodes + read their children *once* at view creation; its differ defaults trackBy to + the expansion key. An id-keyed differ therefore "keeps" mutated (replaced) + nodes whose views render stale children forever. Reference keys make every + immutable replacement re-create the affected views. +2. **`[expansionKey]` = node id** + `[isExpanded]="true"` per + `mat-nested-tree-node` — always-expanded navigator that survives view + re-creation (expansion model keyed by id; recreated groups stay open). +3. **Every edit flows through the root signal** (`root.update(...)`); nothing + mutates editor objects in place. The dialog is OnPush: an in-place write + (e.g. `[(ngModel)]="leaf.textValue"`) dirties no signal, so tree labels and + the preview show stale values. All detail-pane bindings are + `[ngModel]` + `(ngModelChange)` → patch handlers. +4. `treeData = computed(() => [root()])` as `[dataSource]` — one top-level row + (the root node); member ids are unique editor-local counters. + +## 7. Value editors (detail pane), by dispatch + +The detail pane's **property select** drives everything: picking a property +swaps the operator select contents (per §5.1) and the **value editor** below. +Dispatch table: + +| effective editor | condition | controls | +|---|---|---| +| text | `kind: string`, valued operator | `matInput` (single) | +| number | `kind: number`, single-value operator | `matInput type=number` with `numberRange` | +| date | `kind: date`, single-value operator | datepicker (`MatDatepickerModule`, `date-fns` adapter per project convention) | +| **between** | any valued kind, `operation = Between` | **two inputs** (low, high) — §7.2 | +| **enum choices** | `kind: enum` (operator fixed to `Equal`) | **multiple-choice checkbox list** — §7.3 | +| custom (valueless) | `operator ∈ customOperators` | none ("this needs no value" hint row) | + +### 7.1 State co-location + +All five value fields live on the leaf (§6.1) and keep their values across +switches (the user's phrase→contains→phrase round-trip must not lose typed +text). `emitTree` reads only the one field the dispatch selects — the others +are discarded, mirroring the existing dialog. + +### 7.2 Between + +- Two inputs (`lowValue`, `highValue`); actslib `Between` is **inclusive on + both bounds** and examines both. +- Validation: both filled; `low <= high` (numeric and date compare; string + Between compares lexicographically per actslib — allowed, no extra rule). +- Emit: `{ property, operation: Between, lowValue, highValue }` (+ + `enumValues` passthrough if the property has it). +- Seed fold-back: a condition with `operation === Between` populates + `lowValue`/`highValue` directly (no group involved). + +### 7.3 Enum: multiple-choice (the first special case) + +The leaf for a `kind: 'enum'` property offers `choices[]` as checkboxes +(`MatSelectionList`+`MatCheckbox` or a checkbox group; label = +`t(labelKey)`); its operator select shows `Equal` (disabled select — operator +is implied by the kind). + +**Emit (Submit):** + +- 1 value chosen → single condition + `{ property, operation: Equal, lowValue: v, enumValues }` +- N > 1 values → a nested group + `{ join: OR, conditions: [ {Equal, v₁, enumValues}, …, {Equal, vₙ, enumValues} ] }` +- 0 values → **invalid** (§9: enum leaf must pick at least one). There is no + "inactive" escape: an all-empty tree is the way to clear the filter. + +Rationale: actslib has no `In` operation; OR-of-`Equal` is the only faithful +encoding of "row's enum value ∈ chosen set", and attaching `enumValues` to +each condition reuses actslib's enum validation (non-members never match). + +**Seed fold-back:** when `seedTree` meets an **OR group whose every member is** +`Equal` **on the same enum property**, it collapses it into ONE multi-choice +leaf (values = the `lowValue`s). The fold is deliberately strict (direct +members, all-`Equal`, same property) so hand-built or legacy definitions that +don't match stay editable as an OR group of word-level `Equal` leaves against… +nothing — the enum editor's operator select only offers `Equal`, so such +groups seed into leaves with the property's editor anyway; a lone +`Equal v` on an enum property seeds to the same leaf with `[v]` checked. + +Consequence: the knowledge page's existing +`KnowledgeItemTypeCondition { field: 'itemType', itemTypes: [] }` maps 1:1 to +the enum leaf (its emit/fold round-trip is lossless for the +`itemTypes → OR-of-Equal` shape the page already evaluates by `includes`). + +### 7.4 Custom (valueless) operators — `isPhrase` + +`FilterCustomOperator.recognize` runs first during seed fold-back, so +`Contains ' '` on a property that declares the `isPhrase` custom op folds back +into that custom leaf (today's behavior, where `isPhrase` is stored natively +in `VocabularyCondition`). The `emit()` runs on Submit. `prepareValue` is +*skipped* for custom operators (they own their value). The recognizer must be +unambiguous — pages that also offer literal `' '` contains (none today) would +lose that distinction; documented as the hook's contract. + +## 8. UI layout (follows the vocabulary dialog) + +``` +┌───────────────────────────── title: t(titleKey) ─────────────────────────────┐ +│ ┌─ tree pane (splitLeft%) ──────┐ │ ┌─ detail pane (rest) ──────────────────┐ │ +│ │ [+cond] [+group] [delete] │ │ │ (group → join select + hint) │ │ +│ │ ─────────────────────────────│ │ │ (leaf → property select │ │ +│ │ ▾ mat-tree, always expanded │ │ │ operator select │ │ +│ │ rows: icon + label + │◄┼►│ value editor per §7) │ │ +│ │ invalid ⚠ icon │ │ │ │ │ +│ │ (draggable splitter) │ │ │ │ │ +│ └──────────────────────────────┘ │ └───────────────────────────────────────┘ │ +│ preview: