Skip to content

Commit 0803616

Browse files
authored
SF-3497 Fix no Lynx overlay when Lynx problems panel nav to another chapter (#3374)
1 parent 18309e7 commit 0803616

File tree

5 files changed

+439
-43
lines changed

5 files changed

+439
-43
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { LynxInsight } from '../lynx-insight';
44

55
@Injectable()
66
export abstract class InsightRenderService {
7-
abstract render(insights: LynxInsight[], editor: LynxableEditor): void;
7+
abstract render(insights: LynxInsight[], editor: LynxableEditor): Promise<void>;
88
abstract removeAllInsightFormatting(editor: LynxableEditor): void;
99
abstract renderActionOverlay(
1010
insights: LynxInsight[],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,367 @@
1+
import { Component, DestroyRef, NO_ERRORS_SCHEMA, ViewChild } from '@angular/core';
2+
import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
3+
import { Delta } from 'quill';
4+
import { BehaviorSubject } from 'rxjs';
5+
import { anything, instance, mock, verify, when } from 'ts-mockito';
6+
import { configureTestingModule } from 'xforge-common/test-utils';
7+
import { TextDocId } from '../../../../../core/models/text-doc';
8+
import { EditorReadyService } from '../base-services/editor-ready.service';
9+
import { InsightRenderService } from '../base-services/insight-render.service';
10+
import { LynxableEditor, LynxTextModelConverter } from '../lynx-editor';
11+
import { LynxInsight, LynxInsightDisplayState, LynxInsightRange } from '../lynx-insight';
12+
import { LynxInsightOverlayService } from '../lynx-insight-overlay.service';
13+
import { LynxInsightStateService } from '../lynx-insight-state.service';
14+
import { LynxWorkspaceService } from '../lynx-workspace.service';
15+
import { QuillEditorReadyService } from '../quill-services/quill-editor-ready.service';
16+
import { QuillInsightRenderService } from '../quill-services/quill-insight-render.service';
17+
import { LynxInsightEditorObjectsComponent } from './lynx-insight-editor-objects.component';
18+
19+
const mockInsightRenderService = mock(QuillInsightRenderService);
20+
const mockInsightStateService = mock(LynxInsightStateService);
21+
const mockEditorReadyService = mock(QuillEditorReadyService);
22+
const mockOverlayService = mock(LynxInsightOverlayService);
23+
const mockLynxWorkspaceService = mock(LynxWorkspaceService);
24+
const mockDestroyRef = mock(DestroyRef);
25+
const mockTextModelConverter = mock<LynxTextModelConverter>();
26+
27+
describe('LynxInsightEditorObjectsComponent', () => {
28+
configureTestingModule(() => ({
29+
declarations: [HostComponent, LynxInsightEditorObjectsComponent],
30+
providers: [
31+
{ provide: InsightRenderService, useMock: mockInsightRenderService },
32+
{ provide: LynxInsightStateService, useMock: mockInsightStateService },
33+
{ provide: EditorReadyService, useMock: mockEditorReadyService },
34+
{ provide: LynxInsightOverlayService, useMock: mockOverlayService },
35+
{ provide: LynxWorkspaceService, useMock: mockLynxWorkspaceService },
36+
{ provide: DestroyRef, useMock: mockDestroyRef }
37+
],
38+
schemas: [NO_ERRORS_SCHEMA]
39+
}));
40+
41+
afterEach(() => {
42+
expect(1).toBe(1); // Avoid 'no expectations'
43+
});
44+
45+
describe('insight rendering pipeline', () => {
46+
it('should wait for editor ready before rendering insights', fakeAsync(() => {
47+
const env = new TestEnvironment({ initialEditorReady: false });
48+
49+
// Set insights after component initialization
50+
env.setFilteredInsights([env.createTestInsight()]);
51+
tick();
52+
53+
// Verify render was not called
54+
verify(mockInsightRenderService.render(anything(), anything())).never();
55+
56+
// Make editor ready
57+
env.setEditorReady(true);
58+
tick();
59+
flush();
60+
61+
// Verify render was called
62+
verify(mockInsightRenderService.render(anything(), anything())).once();
63+
}));
64+
65+
it('should close overlays when editor becomes ready', fakeAsync(() => {
66+
const env = new TestEnvironment({ initialEditorReady: false });
67+
68+
env.setEditorReady(true);
69+
tick();
70+
71+
verify(mockOverlayService.close()).once();
72+
}));
73+
74+
it('should not render action overlay until insights are rendered', fakeAsync(() => {
75+
const env = new TestEnvironment();
76+
const testInsight = env.createTestInsight();
77+
78+
// Setup render service to not complete immediately
79+
const renderPromise = new Promise<void>(resolve => {
80+
setTimeout(() => resolve(), 100);
81+
});
82+
when(mockInsightRenderService.render(anything(), anything())).thenReturn(renderPromise);
83+
84+
env.setEditorReady(true);
85+
env.setFilteredInsights([testInsight]);
86+
87+
// Set display state that would trigger action overlay
88+
env.setDisplayState({
89+
activeInsightIds: [testInsight.id],
90+
actionOverlayActive: true,
91+
promptActive: true,
92+
cursorActiveInsightIds: []
93+
});
94+
95+
tick(50); // Before render completes
96+
97+
// Action overlay should not be rendered yet
98+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).never();
99+
100+
tick(100); // After render completes
101+
flush();
102+
103+
// Now action overlay should be rendered
104+
verify(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).once();
105+
}));
106+
107+
it('should render cursor active state when cursor active insight IDs change', fakeAsync(() => {
108+
const env = new TestEnvironment();
109+
const testInsight = env.createTestInsight();
110+
111+
env.setEditorReady(true);
112+
env.setFilteredInsights([testInsight]);
113+
tick();
114+
flush();
115+
116+
// Set cursor active state
117+
env.setDisplayState({
118+
activeInsightIds: [],
119+
actionOverlayActive: false,
120+
promptActive: false,
121+
cursorActiveInsightIds: [testInsight.id]
122+
});
123+
tick();
124+
125+
verify(mockInsightRenderService.renderCursorActiveState(anything(), anything())).atLeast(1);
126+
}));
127+
});
128+
129+
describe('selection change handling', () => {
130+
it('should update display state on selection change with overlapping insights', fakeAsync(() => {
131+
const env = new TestEnvironment();
132+
const testInsight = env.createTestInsight({ range: { index: 5, length: 10 } });
133+
134+
env.setEditorReady(true);
135+
env.setFilteredInsights([testInsight]);
136+
tick();
137+
flush();
138+
139+
// Simulate selection change at cursor position that overlaps with insight
140+
const selection: LynxInsightRange = { index: 8, length: 0 }; // Cursor inside insight
141+
env.triggerSelectionChange(selection);
142+
tick();
143+
144+
verify(mockInsightStateService.updateDisplayState(anything())).atLeast(1);
145+
}));
146+
147+
it('should update display state with empty arrays when cursor does not overlap insights', fakeAsync(() => {
148+
const env = new TestEnvironment();
149+
const testInsight = env.createTestInsight({ range: { index: 5, length: 10 } });
150+
151+
env.setEditorReady(true);
152+
env.setFilteredInsights([testInsight]);
153+
tick();
154+
flush();
155+
156+
// Simulate cursor outside of insight range
157+
const selection: LynxInsightRange = { index: 20, length: 0 }; // Cursor outside insight
158+
env.triggerSelectionChange(selection);
159+
tick(); // Process selection change event
160+
161+
// Verify updateDisplayState was called (check that it was called, regardless of initialization calls)
162+
verify(mockInsightStateService.updateDisplayState(anything())).atLeast(1);
163+
}));
164+
165+
it('should not update display state when overlay is open', fakeAsync(() => {
166+
const env = new TestEnvironment();
167+
168+
when(mockOverlayService.isOpen).thenReturn(true);
169+
170+
env.setEditorReady(true);
171+
env.setFilteredInsights([env.createTestInsight()]);
172+
tick();
173+
flush();
174+
175+
const selection: LynxInsightRange = { index: 0, length: 0 };
176+
env.triggerSelectionChange(selection);
177+
178+
// Should not call updateDisplayState when overlay is open
179+
verify(mockInsightStateService.updateDisplayState(anything())).never();
180+
}));
181+
182+
it('should not update display state when selection is null (chapter navigation)', fakeAsync(() => {
183+
const env = new TestEnvironment();
184+
185+
env.setEditorReady(true);
186+
env.setFilteredInsights([env.createTestInsight()]);
187+
tick();
188+
flush();
189+
190+
// Simulate null selection (happens during chapter navigation via lynx panel)
191+
env.triggerSelectionChange(undefined);
192+
tick(); // Process selection change event
193+
194+
// Should not call updateDisplayState for null selection to avoid interfering with navigation
195+
verify(mockInsightStateService.updateDisplayState(anything())).never();
196+
}));
197+
});
198+
199+
describe('text change handling', () => {
200+
it('should handle text changes and apply edits', fakeAsync(() => {
201+
const env = new TestEnvironment();
202+
const delta = new Delta([{ insert: 'test' }]);
203+
const editDelta = new Delta([{ insert: 'edited' }]);
204+
205+
when(mockLynxWorkspaceService.getOnTypeEdits(anything())).thenResolve([editDelta]);
206+
when(mockTextModelConverter.dataDeltaToEditorDelta(anything())).thenReturn(editDelta);
207+
208+
env.setEditorReady(true);
209+
tick(); // Process editor ready state
210+
211+
env.triggerTextChange(delta);
212+
tick(); // Process text change event
213+
flush();
214+
215+
verify(mockLynxWorkspaceService.getOnTypeEdits(delta)).once();
216+
expect(env.hostComponent.editor!.updateContents).toHaveBeenCalledWith(editDelta, 'user');
217+
}));
218+
});
219+
220+
describe('cleanup', () => {
221+
it('should remove all insight formatting on destroy', fakeAsync(() => {
222+
const env = new TestEnvironment();
223+
224+
tick();
225+
env.fixture.destroy();
226+
227+
verify(mockInsightRenderService.removeAllInsightFormatting(anything())).atLeast(1);
228+
}));
229+
});
230+
});
231+
232+
@Component({
233+
template: `
234+
<app-lynx-insight-editor-objects [editor]="editor" [lynxTextModelConverter]="textModelConverter">
235+
</app-lynx-insight-editor-objects>
236+
`
237+
})
238+
class HostComponent {
239+
@ViewChild(LynxInsightEditorObjectsComponent) component!: LynxInsightEditorObjectsComponent;
240+
editor?: LynxableEditor;
241+
textModelConverter?: LynxTextModelConverter;
242+
}
243+
244+
interface TestEnvArgs {
245+
initialEditorReady?: boolean;
246+
}
247+
248+
class TestEnvironment {
249+
fixture: ComponentFixture<HostComponent>;
250+
hostComponent: HostComponent;
251+
component: LynxInsightEditorObjectsComponent;
252+
private eventHandlers: Map<string, Array<(...args: any[]) => void>> = new Map();
253+
254+
private editorReadySubject: BehaviorSubject<boolean>;
255+
private filteredInsightsSubject: BehaviorSubject<LynxInsight[]>;
256+
private displayStateSubject: BehaviorSubject<LynxInsightDisplayState>;
257+
258+
constructor(args: TestEnvArgs = {}) {
259+
const textModelConverter = instance(mockTextModelConverter);
260+
const initialEditorReady = args.initialEditorReady ?? true;
261+
262+
this.editorReadySubject = new BehaviorSubject<boolean>(initialEditorReady);
263+
this.filteredInsightsSubject = new BehaviorSubject<LynxInsight[]>([]);
264+
this.displayStateSubject = new BehaviorSubject<LynxInsightDisplayState>({
265+
activeInsightIds: [],
266+
actionOverlayActive: false,
267+
promptActive: false,
268+
cursorActiveInsightIds: []
269+
});
270+
271+
// Create mock editor
272+
const mockRoot = document.createElement('div');
273+
const actualEditor = {
274+
root: mockRoot,
275+
on: (eventName: string, handler: (...args: any[]) => void) => {
276+
if (!this.eventHandlers.has(eventName)) {
277+
this.eventHandlers.set(eventName, []);
278+
}
279+
this.eventHandlers.get(eventName)!.push(handler);
280+
return actualEditor;
281+
},
282+
off: (eventName: string, handler: (...args: any[]) => void) => {
283+
if (this.eventHandlers.has(eventName)) {
284+
const handlers = this.eventHandlers.get(eventName)!;
285+
const index = handlers.indexOf(handler);
286+
if (index > -1) {
287+
handlers.splice(index, 1);
288+
}
289+
}
290+
return actualEditor;
291+
},
292+
updateContents: jasmine.createSpy('updateContents').and.returnValue(new Delta())
293+
} as any;
294+
295+
when(mockEditorReadyService.listenEditorReadyState(anything())).thenReturn(this.editorReadySubject);
296+
when(mockInsightStateService.filteredChapterInsights$).thenReturn(this.filteredInsightsSubject);
297+
when(mockInsightStateService.displayState$).thenReturn(this.displayStateSubject);
298+
when(mockInsightStateService.updateDisplayState(anything())).thenReturn();
299+
when(mockInsightRenderService.render(anything(), anything())).thenResolve();
300+
when(mockInsightRenderService.renderActionOverlay(anything(), anything(), anything(), anything())).thenResolve();
301+
when(mockInsightRenderService.renderCursorActiveState(anything(), anything())).thenResolve();
302+
when(mockInsightRenderService.removeAllInsightFormatting(anything())).thenResolve();
303+
when(mockOverlayService.close()).thenResolve();
304+
when(mockOverlayService.isOpen).thenReturn(false);
305+
when(mockLynxWorkspaceService.getOnTypeEdits(anything())).thenResolve([]);
306+
when(mockTextModelConverter.dataDeltaToEditorDelta(anything())).thenCall((delta: Delta) => delta);
307+
308+
// Setup text model converter to return ranges as-is (prevents null range issues)
309+
when(mockTextModelConverter.dataRangeToEditorRange(anything())).thenCall((range: LynxInsightRange) => range);
310+
311+
this.fixture = TestBed.createComponent(HostComponent);
312+
this.hostComponent = this.fixture.componentInstance;
313+
314+
// Set the inputs before calling detectChanges to ensure they're available during ngOnInit
315+
this.hostComponent.editor = actualEditor;
316+
this.hostComponent.textModelConverter = textModelConverter;
317+
318+
this.fixture.detectChanges();
319+
this.component = this.hostComponent.component;
320+
}
321+
322+
setEditorReady(ready: boolean): void {
323+
this.editorReadySubject.next(ready);
324+
}
325+
326+
setFilteredInsights(insights: LynxInsight[]): void {
327+
this.filteredInsightsSubject.next(insights);
328+
}
329+
330+
setDisplayState(state: Partial<LynxInsightDisplayState>): void {
331+
const defaultState = {
332+
activeInsightIds: [],
333+
actionOverlayActive: false,
334+
promptActive: false,
335+
cursorActiveInsightIds: []
336+
};
337+
const fullState = { ...defaultState, ...state };
338+
this.displayStateSubject.next(fullState);
339+
}
340+
341+
triggerSelectionChange(selection: LynxInsightRange | undefined): void {
342+
const handlers = this.eventHandlers.get('selection-change');
343+
if (handlers) {
344+
handlers.forEach(handler => handler([selection]));
345+
}
346+
}
347+
348+
triggerTextChange(delta: Delta): void {
349+
const handlers = this.eventHandlers.get('text-change');
350+
if (handlers) {
351+
handlers.forEach(handler => handler([delta, new Delta(), 'user']));
352+
}
353+
}
354+
355+
createTestInsight(props: Partial<LynxInsight> = {}): LynxInsight {
356+
return {
357+
id: props.id ?? 'test-insight-1',
358+
type: props.type ?? 'warning',
359+
textDocId: props.textDocId ?? new TextDocId('project1', 40, 1),
360+
range: props.range ?? { index: 5, length: 10 },
361+
code: props.code ?? 'TEST001',
362+
source: props.source ?? 'test-source',
363+
description: props.description ?? 'Test insight description',
364+
...props
365+
};
366+
}
367+
}

0 commit comments

Comments
 (0)