Skip to content

Commit 0f792dd

Browse files
authored
SF-3443 SF-3494 Filter out Lynx insights for chapters that cannot be edited (#3355)
1 parent 4bc37e4 commit 0f792dd

5 files changed

Lines changed: 289 additions & 22 deletions

File tree

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.spec.ts

Lines changed: 197 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import { TestBed } from '@angular/core/testing';
22
import { LynxInsightFilter } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight';
3+
import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info';
4+
import { anything, mock, verify, when } from 'ts-mockito';
35
import { RouteBookChapter } from 'xforge-common/activated-book-chapter.service';
46
import { configureTestingModule } from 'xforge-common/test-utils';
57
import { TextDocId } from '../../../../core/models/text-doc';
8+
import { TextDocService } from '../../../../core/text-doc.service';
69
import { LynxInsight } from './lynx-insight';
710
import { LynxInsightFilterService } from './lynx-insight-filter.service';
811

912
describe('LynxInsightFilterService', () => {
1013
let service: LynxInsightFilterService;
14+
const mockTextDocService = mock(TextDocService);
1115

1216
function createMockInsight(
1317
type: 'info' | 'warning' | 'error' = 'warning',
@@ -25,11 +29,24 @@ describe('LynxInsightFilterService', () => {
2529
};
2630
}
2731

32+
function createMockTextInfo(bookNum: number = 40): TextInfo {
33+
return {
34+
bookNum,
35+
hasSource: false,
36+
chapters: [],
37+
permissions: {}
38+
};
39+
}
40+
2841
configureTestingModule(() => ({
29-
providers: [LynxInsightFilterService]
42+
providers: [LynxInsightFilterService, { provide: TextDocService, useMock: mockTextDocService }]
3043
}));
3144

3245
beforeEach(() => {
46+
// Set up default mocks
47+
when(mockTextDocService.hasChapterEditPermissionForText(anything(), anything())).thenReturn(true);
48+
when(mockTextDocService.isUsfmValidForText(anything(), anything())).thenReturn(true);
49+
3350
service = TestBed.inject(LynxInsightFilterService);
3451
});
3552

@@ -182,5 +199,184 @@ describe('LynxInsightFilterService', () => {
182199

183200
expect(result).toBe('project');
184201
});
202+
203+
it('should return false when permissions check fails', () => {
204+
const insight = createMockInsight();
205+
const filter: LynxInsightFilter = {
206+
types: ['warning'],
207+
scope: 'project',
208+
includeDismissed: true
209+
};
210+
const bookChapter: RouteBookChapter = { bookId: 'MAT', chapter: 1 };
211+
const dismissedIds: string[] = [];
212+
const projectTexts: TextInfo[] = [createMockTextInfo()];
213+
214+
// Mock permission check to return false
215+
when(mockTextDocService.hasChapterEditPermissionForText(anything(), anything())).thenReturn(false);
216+
217+
const result = service.matchesFilter(insight, filter, bookChapter, dismissedIds, projectTexts);
218+
219+
expect(result).toBeFalse();
220+
});
221+
222+
it('should return false when USFM validity check fails', () => {
223+
const insight = createMockInsight();
224+
const filter: LynxInsightFilter = {
225+
types: ['warning'],
226+
scope: 'project',
227+
includeDismissed: true
228+
};
229+
const bookChapter: RouteBookChapter = { bookId: 'MAT', chapter: 1 };
230+
const dismissedIds: string[] = [];
231+
const projectTexts: TextInfo[] = [createMockTextInfo()];
232+
233+
// Mock USFM validity check to return false
234+
when(mockTextDocService.isUsfmValidForText(anything(), anything())).thenReturn(false);
235+
236+
const result = service.matchesFilter(insight, filter, bookChapter, dismissedIds, projectTexts);
237+
238+
expect(result).toBeFalse();
239+
});
240+
241+
it('should return false when book not found in project texts', () => {
242+
const insight = createMockInsight('warning', 42); // Luke
243+
const filter: LynxInsightFilter = {
244+
types: ['warning'],
245+
scope: 'project',
246+
includeDismissed: true
247+
};
248+
const bookChapter: RouteBookChapter = { bookId: 'MAT', chapter: 1 };
249+
const dismissedIds: string[] = [];
250+
const projectTexts: TextInfo[] = [createMockTextInfo(40)]; // Only Matthew
251+
252+
const result = service.matchesFilter(insight, filter, bookChapter, dismissedIds, projectTexts);
253+
254+
expect(result).toBeFalse();
255+
});
256+
257+
it('should return true when permissions check passes', () => {
258+
const insight = createMockInsight();
259+
const filter: LynxInsightFilter = {
260+
types: ['warning'],
261+
scope: 'project',
262+
includeDismissed: true
263+
};
264+
const bookChapter: RouteBookChapter = { bookId: 'MAT', chapter: 1 };
265+
const dismissedIds: string[] = [];
266+
const projectTexts: TextInfo[] = [createMockTextInfo()];
267+
268+
// Mock permission checks to return true (default setup)
269+
when(mockTextDocService.hasChapterEditPermissionForText(anything(), anything())).thenReturn(true);
270+
when(mockTextDocService.isUsfmValidForText(anything(), anything())).thenReturn(true);
271+
272+
const result = service.matchesFilter(insight, filter, bookChapter, dismissedIds, projectTexts);
273+
274+
expect(result).toBeTrue();
275+
});
276+
277+
it('should return true when projectTexts is undefined (no permission filtering)', () => {
278+
const insight = createMockInsight();
279+
const filter: LynxInsightFilter = {
280+
types: ['warning'],
281+
scope: 'project',
282+
includeDismissed: true
283+
};
284+
const bookChapter: RouteBookChapter = { bookId: 'MAT', chapter: 1 };
285+
const dismissedIds: string[] = [];
286+
287+
const result = service.matchesFilter(insight, filter, bookChapter, dismissedIds);
288+
289+
expect(result).toBeTrue();
290+
});
291+
292+
it('should call permission methods with correct parameters when projectTexts provided', () => {
293+
const insight = createMockInsight('warning', 40, 1);
294+
const filter: LynxInsightFilter = {
295+
types: ['warning'],
296+
scope: 'project',
297+
includeDismissed: true
298+
};
299+
const bookChapter: RouteBookChapter = { bookId: 'MAT', chapter: 1 };
300+
const dismissedIds: string[] = [];
301+
const projectTexts: TextInfo[] = [createMockTextInfo(40)];
302+
303+
when(mockTextDocService.hasChapterEditPermissionForText(anything(), anything())).thenReturn(true);
304+
when(mockTextDocService.isUsfmValidForText(anything(), anything())).thenReturn(true);
305+
306+
const result = service.matchesFilter(insight, filter, bookChapter, dismissedIds, projectTexts);
307+
308+
verify(mockTextDocService.hasChapterEditPermissionForText(anything(), 1)).once();
309+
verify(mockTextDocService.isUsfmValidForText(anything(), 1)).once();
310+
expect(result).toBeTrue();
311+
});
312+
});
313+
314+
describe('hasDisplayPermission', () => {
315+
it('should return false when text not found', () => {
316+
const insight = createMockInsight('warning', 42); // Luke
317+
const projectTexts: TextInfo[] = [createMockTextInfo(40)]; // Only Matthew
318+
319+
const result = service.hasDisplayPermission(insight, projectTexts);
320+
321+
expect(result).toBeFalse();
322+
});
323+
324+
it('should return false when edit permission is false', () => {
325+
const insight = createMockInsight();
326+
const projectTexts: TextInfo[] = [createMockTextInfo()];
327+
328+
when(mockTextDocService.hasChapterEditPermissionForText(anything(), anything())).thenReturn(false);
329+
330+
const result = service.hasDisplayPermission(insight, projectTexts);
331+
332+
expect(result).toBeFalse();
333+
});
334+
335+
it('should return false when USFM is invalid', () => {
336+
const insight = createMockInsight();
337+
const projectTexts: TextInfo[] = [createMockTextInfo()];
338+
339+
when(mockTextDocService.isUsfmValidForText(anything(), anything())).thenReturn(false);
340+
341+
const result = service.hasDisplayPermission(insight, projectTexts);
342+
343+
expect(result).toBeFalse();
344+
});
345+
346+
it('should return true when all checks pass', () => {
347+
const insight = createMockInsight();
348+
const projectTexts: TextInfo[] = [createMockTextInfo()];
349+
350+
when(mockTextDocService.hasChapterEditPermissionForText(anything(), anything())).thenReturn(true);
351+
when(mockTextDocService.isUsfmValidForText(anything(), anything())).thenReturn(true);
352+
353+
const result = service.hasDisplayPermission(insight, projectTexts);
354+
355+
verify(mockTextDocService.hasChapterEditPermissionForText(anything(), 1)).once();
356+
verify(mockTextDocService.isUsfmValidForText(anything(), 1)).once();
357+
expect(result).toBeTrue();
358+
});
359+
360+
it('should call methods with correct TextInfo and chapter number', () => {
361+
const insight = createMockInsight('warning', 42, 3); // Luke 3
362+
const matthewText = createMockTextInfo(40);
363+
const lukeText = createMockTextInfo(42);
364+
const projectTexts: TextInfo[] = [matthewText, lukeText];
365+
366+
when(mockTextDocService.hasChapterEditPermissionForText(anything(), anything())).thenReturn(true);
367+
when(mockTextDocService.isUsfmValidForText(anything(), anything())).thenReturn(true);
368+
369+
service.hasDisplayPermission(insight, projectTexts);
370+
371+
// Should call with Luke 3
372+
verify(mockTextDocService.hasChapterEditPermissionForText(lukeText, 3)).once();
373+
verify(mockTextDocService.isUsfmValidForText(lukeText, 3)).once();
374+
375+
// Should not call with Matthew
376+
verify(mockTextDocService.hasChapterEditPermissionForText(matthewText, anything())).never();
377+
verify(mockTextDocService.isUsfmValidForText(matthewText, anything())).never();
378+
379+
expect(1).toBe(1);
380+
});
185381
});
186382
});

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-filter.service.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { Injectable } from '@angular/core';
22
import { Canon } from '@sillsdev/scripture';
33
import { LynxInsightFilter, LynxInsightFilterScope } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight';
4+
import { TextInfo } from 'realtime-server/lib/esm/scriptureforge/models/text-info';
45
import { RouteBookChapter } from 'xforge-common/activated-book-chapter.service';
6+
import { TextDocService } from '../../../../core/text-doc.service';
57
import { LynxInsight } from './lynx-insight';
68

79
@Injectable({
810
providedIn: 'root'
911
})
1012
export class LynxInsightFilterService {
11-
constructor() {}
13+
constructor(private readonly textDocService: TextDocService) {}
1214

1315
matchesFilter(
1416
insight: LynxInsight,
1517
filter: LynxInsightFilter,
1618
bookChapter: RouteBookChapter,
17-
dismissedIds: string[]
19+
dismissedIds: string[],
20+
projectTexts?: TextInfo[]
1821
): boolean {
1922
const routeBookNum: number | undefined = bookChapter.bookId ? Canon.bookIdToNumber(bookChapter.bookId) : undefined;
2023
const routeChapter = bookChapter.chapter;
@@ -28,6 +31,11 @@ export class LynxInsightFilterService {
2831
return false;
2932
}
3033

34+
// Check edit permissions and USFM validity if project texts are provided
35+
if (projectTexts != null && !this.hasDisplayPermission(insight, projectTexts)) {
36+
return false;
37+
}
38+
3139
if (filter.scope === 'project') {
3240
return true;
3341
}
@@ -58,4 +66,35 @@ export class LynxInsightFilterService {
5866
return 'project';
5967
}
6068
}
69+
70+
/**
71+
* Check if an insight has display permission (edit permission and USFM validity).
72+
* @param insight The insight to check.
73+
* @param projectTexts The project texts to check permissions against.
74+
* @returns True if the insight should be displayed, false otherwise.
75+
*/
76+
hasDisplayPermission(insight: LynxInsight, projectTexts: TextInfo[]): boolean {
77+
// Find the text info for this book
78+
const text: TextInfo | undefined = projectTexts.find(t => t.bookNum === insight.textDocId.bookNum);
79+
if (text == null) {
80+
return false;
81+
}
82+
83+
// Check chapter edit permission
84+
const hasEditPermission: boolean | undefined = this.textDocService.hasChapterEditPermissionForText(
85+
text,
86+
insight.textDocId.chapterNum
87+
);
88+
if (!hasEditPermission) {
89+
return false;
90+
}
91+
92+
// Check USFM validity
93+
const isUsfmValid: boolean = this.textDocService.isUsfmValidForText(text, insight.textDocId.chapterNum);
94+
if (!isUsfmValid) {
95+
return false;
96+
}
97+
98+
return true;
99+
}
61100
}

src/SIL.XForge.Scripture/ClientApp/src/app/translate/editor/lynx/insights/lynx-insight-state.service.spec.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { TestBed } from '@angular/core/testing';
2-
import { LynxInsightType } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight';
2+
import { LynxInsightFilter, LynxInsightType } from 'realtime-server/lib/esm/scriptureforge/models/lynx-insight';
3+
import { createTestProject } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-test-data';
4+
import { createTestProjectUserConfig } from 'realtime-server/lib/esm/scriptureforge/models/sf-project-user-config-test-data';
5+
import { TextInfo } from 'realtime-server/scriptureforge/models/text-info';
36
import { BehaviorSubject, firstValueFrom, Subject } from 'rxjs';
47
import { anything, instance, mock, when } from 'ts-mockito';
58
import { ActivatedBookChapterService, RouteBookChapter } from 'xforge-common/activated-book-chapter.service';
69
import { ActivatedProjectUserConfigService } from 'xforge-common/activated-project-user-config.service';
10+
import { ActivatedProjectService } from 'xforge-common/activated-project.service';
711
import { configureTestingModule } from 'xforge-common/test-utils';
8-
import { createTestProjectUserConfig } from '../../../../../../../../RealtimeServer/scriptureforge/models/sf-project-user-config-test-data';
12+
import { SFProjectDoc } from '../../../../core/models/sf-project-doc';
913
import { SFProjectUserConfigDoc } from '../../../../core/models/sf-project-user-config-doc';
1014
import { TextDocId } from '../../../../core/models/text-doc';
1115
import { LynxInsight } from './lynx-insight';
@@ -33,6 +37,7 @@ describe('LynxInsightStateService', () => {
3337

3438
const mockInsightFilterService = mock<LynxInsightFilterService>();
3539
const mockActivatedBookChapterService = mock<ActivatedBookChapterService>();
40+
const mockActivatedProjectService = mock<ActivatedProjectService>();
3641
const mockActivatedProjectUserConfigService = mock<ActivatedProjectUserConfigService>();
3742
const mockLynxWorkspaceService = mock<LynxWorkspaceService>();
3843
const mockProjectUserConfigDoc = mock(SFProjectUserConfigDoc);
@@ -72,6 +77,7 @@ describe('LynxInsightStateService', () => {
7277
LynxInsightStateService,
7378
{ provide: LynxInsightFilterService, useMock: mockInsightFilterService },
7479
{ provide: ActivatedBookChapterService, useMock: mockActivatedBookChapterService },
80+
{ provide: ActivatedProjectService, useMock: mockActivatedProjectService },
7581
{ provide: ActivatedProjectUserConfigService, useMock: mockActivatedProjectUserConfigService },
7682
{ provide: LynxWorkspaceService, useMock: mockLynxWorkspaceService }
7783
]
@@ -101,14 +107,27 @@ describe('LynxInsightStateService', () => {
101107
instance(mockProjectUserConfigDoc)
102108
);
103109

110+
const projectDoc$ = new BehaviorSubject<SFProjectDoc | undefined>({
111+
id: 'project1',
112+
data: createTestProject()
113+
} as SFProjectDoc);
114+
104115
when(mockLynxWorkspaceService.rawInsightSource$).thenReturn(rawInsightSource);
105116
when(mockLynxWorkspaceService.currentInsights).thenReturn(testInsights);
106117
when(mockActivatedBookChapterService.activatedBookChapter$).thenReturn(activatedBookChapter);
118+
when(mockActivatedProjectService.projectDoc$).thenReturn(projectDoc$);
107119
when(mockActivatedProjectUserConfigService.projectUserConfig$).thenReturn(projectUserConfig$);
108120
when(mockActivatedProjectUserConfigService.projectUserConfigDoc$).thenReturn(projectUserConfigDoc$);
109121

110-
when(mockInsightFilterService.matchesFilter(anything(), anything(), anything(), anything())).thenCall(
111-
(insight: LynxInsight, filter: any, _bookChapter: RouteBookChapter, dismissedIds: string[]) => {
122+
when(mockInsightFilterService.hasDisplayPermission(anything(), anything())).thenReturn(true);
123+
when(mockInsightFilterService.matchesFilter(anything(), anything(), anything(), anything(), anything())).thenCall(
124+
(
125+
insight: LynxInsight,
126+
filter: LynxInsightFilter,
127+
_bookChapter: RouteBookChapter,
128+
dismissedIds: string[],
129+
_projectTexts?: TextInfo[]
130+
) => {
112131
if (!filter.types.includes(insight.type)) {
113132
return false;
114133
}

0 commit comments

Comments
 (0)