@@ -181,15 +181,18 @@ describe('ChatPanelComponent', () => {
181181 } ) ;
182182
183183 describe ( 'Eval Edit Mode' , ( ) => {
184+ beforeEach ( ( ) => {
185+ component . evalCase = {
186+ evalId : '1' ,
187+ conversation : [ ] ,
188+ sessionInput : { } ,
189+ creationTimestamp : 123 ,
190+ } ;
191+ component . isEvalEditMode = true ;
192+ } ) ;
193+
184194 it (
185195 'should show edit/delete buttons for text messages' , async ( ) => {
186- component . evalCase = {
187- evalId : '1' ,
188- conversation : [ ] ,
189- sessionInput : { } ,
190- creationTimestamp : 123 ,
191- } ;
192- component . isEvalEditMode = true ;
193196 component . messages =
194197 [ { role : 'bot' , text : 'eval message' , eventId : '1' } ] ;
195198 fixture . detectChanges ( ) ;
@@ -203,13 +206,6 @@ describe('ChatPanelComponent', () => {
203206 } ) ;
204207
205208 it ( 'should show edit button for function calls' , async ( ) => {
206- component . evalCase = {
207- evalId : '1' ,
208- conversation : [ ] ,
209- sessionInput : { } ,
210- creationTimestamp : 123 ,
211- } ;
212- component . isEvalEditMode = true ;
213209 component . messages =
214210 [ { role : 'bot' , functionCall : { name : 'func1' } , eventId : '1' } ] ;
215211 component . isEditFunctionArgsEnabled = true ;
@@ -224,13 +220,6 @@ describe('ChatPanelComponent', () => {
224220
225221 it (
226222 'should emit editEvalCaseMessage when edit is clicked' , async ( ) => {
227- component . evalCase = {
228- evalId : '1' ,
229- conversation : [ ] ,
230- sessionInput : { } ,
231- creationTimestamp : 123 ,
232- } ;
233- component . isEvalEditMode = true ;
234223 const message = { role : 'bot' , text : 'eval message' , eventId : '1' } ;
235224 component . messages = [ message ] ;
236225 spyOn ( component . editEvalCaseMessage , 'emit' ) ;
@@ -247,13 +236,6 @@ describe('ChatPanelComponent', () => {
247236 it (
248237 'should emit deleteEvalCaseMessage when delete is clicked' ,
249238 async ( ) => {
250- component . evalCase = {
251- evalId : '1' ,
252- conversation : [ ] ,
253- sessionInput : { } ,
254- creationTimestamp : 123 ,
255- } ;
256- component . isEvalEditMode = true ;
257239 const message = { role : 'bot' , text : 'eval message' , eventId : '1' } ;
258240 component . messages = [ message ] ;
259241 spyOn ( component . deleteEvalCaseMessage , 'emit' ) ;
@@ -270,13 +252,6 @@ describe('ChatPanelComponent', () => {
270252 it (
271253 'should emit editFunctionArgs when edit on function call is clicked' ,
272254 async ( ) => {
273- component . evalCase = {
274- evalId : '1' ,
275- conversation : [ ] ,
276- sessionInput : { } ,
277- creationTimestamp : 123 ,
278- } ;
279- component . isEvalEditMode = true ;
280255 const message = {
281256 role : 'bot' ,
282257 functionCall : { name : 'func1' } ,
@@ -351,80 +326,180 @@ describe('ChatPanelComponent', () => {
351326 } ) ;
352327
353328 describe ( 'Scrolling' , ( ) => {
354- it (
355- 'should scroll to bottom when user sends a message, even if scroll was interrupted' ,
356- fakeAsync ( ( ) => {
357- // Given
358- component . messages = [ { role : 'bot' , text : 'Bot message' } ] ;
359- fixture . detectChanges ( ) ;
360- const scrollContainerElement =
361- component . scrollContainer . nativeElement ;
362- spyOn ( scrollContainerElement , 'scrollTo' ) ;
363- scrollContainerElement . dispatchEvent ( new WheelEvent ( 'wheel' ) ) ;
364- expect ( component . scrollInterrupted ) . toBeTrue ( ) ;
365-
366- // When
367- const oldMessages = component . messages ;
368- component . messages = [ ...oldMessages , { role : 'user' , text : 'User' } ] ;
369- component . ngOnChanges ( {
370- 'messages' : new SimpleChange ( oldMessages , component . messages , false )
371- } ) ;
372- fixture . detectChanges ( ) ;
373- tick ( 50 ) ;
329+ describe ( 'basic scrolling behavior' , ( ) => {
330+ let scrollContainerElement : HTMLElement ;
374331
375- // Then
376- expect ( component . scrollInterrupted ) . toBeFalse ( ) ;
377- expect ( scrollContainerElement . scrollTo ) . toHaveBeenCalled ( ) ;
378- } ) ) ;
332+ beforeEach ( ( ) => {
333+ component . messages = [ { role : 'bot' , text : 'Bot message' } ] ;
334+ fixture . detectChanges ( ) ;
335+ scrollContainerElement = component . scrollContainer . nativeElement ;
336+ } ) ;
379337
380- it (
381- 'should call uiStateService.lazyLoadMessages when scrolled to top' ,
382- fakeAsync ( ( ) => {
383- // Given
384- const initialMessageCount = 50 ;
385- const initialMessages = Array . from (
386- { length : initialMessageCount } ,
387- ( _ , i ) => ( { role : 'bot' , text : `message ${ i } ` } ) ) ;
388- component . messages = initialMessages ;
389- fixture . detectChanges ( ) ;
338+ it (
339+ 'should scroll to bottom when user sends a message, even if scroll was interrupted' ,
340+ fakeAsync ( ( ) => {
341+ spyOn ( scrollContainerElement , 'scrollTo' ) ;
342+ scrollContainerElement . dispatchEvent ( new WheelEvent ( 'wheel' ) ) ;
343+ expect ( component . scrollInterrupted ) . toBeTrue ( ) ;
344+
345+ const oldMessages = component . messages ;
346+ component . messages = [ ...oldMessages , { role : 'user' , text : 'User' } ] ;
347+ component . ngOnChanges ( {
348+ 'messages' :
349+ new SimpleChange ( oldMessages , component . messages , false )
350+ } ) ;
351+ fixture . detectChanges ( ) ;
352+ tick ( 50 ) ;
353+
354+ expect ( component . scrollInterrupted ) . toBeFalse ( ) ;
355+ expect ( scrollContainerElement . scrollTo ) . toHaveBeenCalled ( ) ;
356+ } ) ) ;
357+
358+ it (
359+ 'should call uiStateService.lazyLoadMessages when scrolled to top' ,
360+ fakeAsync ( ( ) => {
361+ const initialMessageCount = 50 ;
362+ const initialMessages = Array . from (
363+ { length : initialMessageCount } ,
364+ ( _ , i ) => ( { role : 'bot' , text : `message ${ i } ` } ) ) ;
365+ component . messages = initialMessages ;
366+ fixture . detectChanges ( ) ;
367+
368+ scrollContainerElement . style . height = '100px' ;
369+ scrollContainerElement . style . overflow = 'auto' ;
370+ scrollContainerElement . scrollTop = 100 ;
371+ fixture . detectChanges ( ) ;
372+
373+ mockUiStateService . newMessagesLoadedResponse . next (
374+ { items : [ ] , nextPageToken : 'initial-token' } ) ;
375+ tick ( ) ;
376+
377+ scrollContainerElement . scrollTop = 0 ;
378+ scrollContainerElement . dispatchEvent ( new Event ( 'scroll' ) ) ;
379+ tick ( 200 ) ;
380+
381+ expect ( mockUiStateService . lazyLoadMessages ) . toHaveBeenCalled ( ) ;
382+
383+ mockUiStateService . lazyLoadMessagesResponse . next ( ) ;
384+
385+ const newMessages = Array . from (
386+ { length : 20 } , ( _ , i ) => ( { role : 'bot' , text : `new ${ i } ` } ) ) ;
387+ component . messages = [ ...newMessages , ...component . messages ] ;
388+ mockUiStateService . newMessagesLoadedResponse . next (
389+ { items : newMessages , nextPageToken : 'next' } ) ;
390+ tick ( ) ;
391+ fixture . detectChanges ( ) ;
392+
393+ expect ( component . messages . length )
394+ . toBe ( initialMessageCount + newMessages . length ) ;
395+ expect ( component . messages [ 0 ] ) . toEqual ( newMessages [ 0 ] ) ;
396+ } ) ) ;
397+ } ) ;
390398
391- const scrollContainerElement =
392- component . scrollContainer . nativeElement ;
393- // Make sure the scroll height is greater than the client height
394- scrollContainerElement . style . height = '100px' ;
395- scrollContainerElement . style . overflow = 'auto' ;
396- scrollContainerElement . scrollTop = 100 ;
397- fixture . detectChanges ( ) ;
399+ describe ( 'when infinity scrolling is enabled' , ( ) => {
400+ beforeEach ( ( ) => {
401+ mockFeatureFlagService . isInfinityMessageScrollingEnabledResponse . next (
402+ true ) ;
403+ } ) ;
398404
399- // Initialize nextPageToken to allow loading more messages
400- mockUiStateService . newMessagesLoadedResponse . next (
401- { items : [ ] , nextPageToken : 'initial-token' } ) ;
402- tick ( ) ;
405+ it ( 'should lazy load messages when session name changes' , ( ) => {
406+ mockUiStateService . lazyLoadMessages . calls . reset ( ) ;
403407
404- // When
405- scrollContainerElement . scrollTop = 0 ;
406- scrollContainerElement . dispatchEvent ( new Event ( 'scroll' ) ) ;
407- tick ( 200 ) ; // Wait for debounce
408+ fixture . componentRef . setInput ( 'sessionName' , 'new-session-id' ) ;
409+ fixture . detectChanges ( ) ;
408410
409- // Then
410- expect ( mockUiStateService . lazyLoadMessages ) . toHaveBeenCalled ( ) ;
411+ expect ( mockUiStateService . lazyLoadMessages )
412+ . toHaveBeenCalledWith ( 'new-session-id' , {
413+ pageSize : 100 ,
414+ pageToken : '' ,
415+ } ) ;
416+ } ) ;
411417
412- mockUiStateService . lazyLoadMessagesResponse . next ( ) ;
418+ describe ( 'when new messages are loaded' , ( ) => {
419+ let scrollContainer : HTMLElement ;
420+ const nextToken = 'updated-token-123' ;
413421
414- // When more messages are loaded
415- const newMessages = Array . from (
416- { length : 20 } , ( _ , i ) => ( { role : 'bot' , text : `new ${ i } ` } ) ) ;
417- component . messages = [ ...newMessages , ...component . messages ] ;
418- mockUiStateService . newMessagesLoadedResponse . next (
419- { items : newMessages , nextPageToken : 'next' } ) ;
420- tick ( ) ;
421- fixture . detectChanges ( ) ;
422+ beforeEach ( fakeAsync ( ( ) => {
423+ scrollContainer = component . scrollContainer . nativeElement ;
422424
423- // Then
424- expect ( component . messages . length )
425- . toBe ( initialMessageCount + newMessages . length ) ;
426- expect ( component . messages [ 0 ] ) . toEqual ( newMessages [ 0 ] ) ;
425+ // Define scrollHeight and scrollTop as simple data properties to
426+ // bypass browser layout constraint logic.
427+ Object . defineProperty ( scrollContainer , 'scrollHeight' , {
428+ value : 1000 ,
429+ configurable : true ,
430+ } ) ;
431+ Object . defineProperty ( scrollContainer , 'scrollTop' , {
432+ value : 0 ,
433+ writable : true ,
434+ configurable : true ,
435+ } ) ;
436+
437+ mockUiStateService . newMessagesLoadedResponse . next (
438+ { items : [ ] , nextPageToken : nextToken } ) ;
427439 } ) ) ;
440+
441+ it (
442+ 'should update nextPageToken and fetch on scroll' , fakeAsync ( ( ) => {
443+ component [ 'onScroll' ] . next (
444+ { target : scrollContainer } as unknown as Event ) ;
445+ tick ( ) ;
446+
447+ expect ( mockUiStateService . lazyLoadMessages )
448+ . toHaveBeenCalledWith (
449+ jasmine . anything ( ) ,
450+ jasmine . objectContaining ( { pageToken : nextToken } ) ) ;
451+ } ) ) ;
452+
453+ it ( 'should restore scroll position' , fakeAsync ( ( ) => {
454+ mockUiStateService . newMessagesLoadedResponse . next ( {
455+ items : [ { role : 'bot' , text : 'message 1' } ] ,
456+ nextPageToken : nextToken
457+ } ) ;
458+ Object . defineProperty (
459+ scrollContainer , 'scrollHeight' ,
460+ { value : 1500 , configurable : true } ) ;
461+
462+ tick ( 50 ) ;
463+
464+ expect ( scrollContainer . scrollTop ) . toBe ( 500 ) ;
465+ } ) ) ;
466+ } ) ;
467+ } ) ;
468+
469+ describe ( 'when infinity scrolling is disabled' , ( ) => {
470+ beforeEach ( ( ) => {
471+ mockFeatureFlagService . isInfinityMessageScrollingEnabledResponse . next (
472+ false ) ;
473+ } ) ;
474+
475+ it (
476+ 'should not lazy load messages when scrolled to top' ,
477+ fakeAsync ( ( ) => {
478+ mockUiStateService . lazyLoadMessages . calls . reset ( ) ;
479+
480+ component . scrollContainer . nativeElement . scrollTop = 0 ;
481+ component [ 'onScroll' ] . next (
482+ { target : component . scrollContainer . nativeElement } as unknown as
483+ Event ) ;
484+ tick ( ) ;
485+
486+ expect ( mockUiStateService . lazyLoadMessages ) . not . toHaveBeenCalled ( ) ;
487+ } ) ) ;
488+
489+ it (
490+ 'should not restore scroll position after loading new messages' ,
491+ fakeAsync ( ( ) => {
492+ const scrollContainer = component . scrollContainer . nativeElement ;
493+ scrollContainer . scrollTop = 0 ;
494+ const originalScrollTop = scrollContainer . scrollTop ;
495+
496+ mockUiStateService . newMessagesLoadedResponse . next (
497+ { items : [ ] , nextPageToken : '' } ) ;
498+ tick ( ) ;
499+
500+ expect ( scrollContainer . scrollTop ) . toBe ( originalScrollTop ) ;
501+ } ) ) ;
502+ } ) ;
428503 } ) ;
429504
430505 describe ( 'disabled features' , ( ) => {
0 commit comments