diff --git a/src-ui/src/app/components/chat/chat/chat.component.spec.ts b/src-ui/src/app/components/chat/chat/chat.component.spec.ts new file mode 100644 index 000000000..0ccb04a99 --- /dev/null +++ b/src-ui/src/app/components/chat/chat/chat.component.spec.ts @@ -0,0 +1,132 @@ +import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' +import { provideHttpClientTesting } from '@angular/common/http/testing' +import { ElementRef } from '@angular/core' +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NavigationEnd, Router } from '@angular/router' +import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Subject } from 'rxjs' +import { ChatService } from 'src/app/services/chat.service' +import { ChatComponent } from './chat.component' + +describe('ChatComponent', () => { + let component: ChatComponent + let fixture: ComponentFixture + let chatService: ChatService + let router: Router + let routerEvents$: Subject + let mockStream$: Subject + + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [NgxBootstrapIconsModule.pick(allIcons), ChatComponent], + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }).compileComponents() + + fixture = TestBed.createComponent(ChatComponent) + router = TestBed.inject(Router) + routerEvents$ = new Subject() + jest + .spyOn(router, 'events', 'get') + .mockReturnValue(routerEvents$.asObservable()) + chatService = TestBed.inject(ChatService) + mockStream$ = new Subject() + jest + .spyOn(chatService, 'streamChat') + .mockReturnValue(mockStream$.asObservable()) + component = fixture.componentInstance + + jest.useFakeTimers() + + fixture.detectChanges() + + component.scrollAnchor.nativeElement.scrollIntoView = jest.fn() + }) + + it('should update documentId on initialization', () => { + jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123') + component.ngOnInit() + expect(component.documentId).toBe(123) + }) + + it('should update documentId on navigation', () => { + component.ngOnInit() + routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456')) + expect(component.documentId).toBe(456) + }) + + it('should return correct placeholder based on documentId', () => { + component.documentId = 123 + expect(component.placeholder).toBe('Ask a question about this document...') + component.documentId = undefined + expect(component.placeholder).toBe('Ask a question about a document...') + }) + + it('should send a message and handle streaming response', () => { + component.input = 'Hello' + component.sendMessage() + + expect(component.messages.length).toBe(2) + expect(component.messages[0].content).toBe('Hello') + expect(component.loading).toBe(true) + + mockStream$.next('Hi') + expect(component.messages[1].content).toBe('H') + mockStream$.next('Hi there') + // advance time to process the typewriter effect + jest.advanceTimersByTime(1000) + expect(component.messages[1].content).toBe('Hi there') + + mockStream$.complete() + expect(component.loading).toBe(false) + expect(component.messages[1].isStreaming).toBe(false) + }) + + it('should handle errors during streaming', () => { + component.input = 'Hello' + component.sendMessage() + + mockStream$.error('Error') + expect(component.messages[1].content).toContain( + '⚠️ Error receiving response.' + ) + expect(component.loading).toBe(false) + }) + + it('should enqueue typewriter chunks correctly', () => { + const message = { content: '', role: 'assistant', isStreaming: true } + component.enqueueTypewriter(null, message as any) // coverage for null + component.enqueueTypewriter('Hello', message as any) + expect(component['typewriterBuffer'].length).toBe(4) + }) + + it('should scroll to bottom after sending a message', () => { + const scrollSpy = jest.spyOn( + ChatComponent.prototype as any, + 'scrollToBottom' + ) + component.input = 'Test' + component.sendMessage() + expect(scrollSpy).toHaveBeenCalled() + }) + + it('should focus chat input when dropdown is opened', () => { + const focus = jest.fn() + component.chatInput = { + nativeElement: { focus: focus }, + } as unknown as ElementRef + + component.onOpenChange(true) + jest.advanceTimersByTime(15) + expect(focus).toHaveBeenCalled() + }) + + it('should send message on Enter key press', () => { + jest.spyOn(component, 'sendMessage') + const event = new KeyboardEvent('keydown', { key: 'Enter' }) + component.searchInputKeyDown(event) + expect(component.sendMessage).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/chat/chat/chat.component.ts b/src-ui/src/app/components/chat/chat/chat.component.ts index 0c97abc4f..7ab29b366 100644 --- a/src-ui/src/app/components/chat/chat/chat.component.ts +++ b/src-ui/src/app/components/chat/chat/chat.component.ts @@ -45,9 +45,11 @@ export class ChatComponent implements OnInit { this.router.events .pipe( filter((event) => event instanceof NavigationEnd), - map(() => this.router.url) + map((event) => (event as NavigationEnd).url) ) .subscribe((url) => { + console.log('URL changed:', url) + this.updateDocumentId(url) }) } @@ -138,12 +140,5 @@ export class ChatComponent implements OnInit { event.preventDefault() this.sendMessage() } - // } else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) { - // if (this.query?.length) { - // this.reset(true) - // } else { - // this.searchInput.nativeElement.blur() - // } - // } } } diff --git a/src-ui/src/app/services/chat.service.spec.ts b/src-ui/src/app/services/chat.service.spec.ts new file mode 100644 index 000000000..b8ca957cb --- /dev/null +++ b/src-ui/src/app/services/chat.service.spec.ts @@ -0,0 +1,58 @@ +import { + HttpEventType, + provideHttpClient, + withInterceptorsFromDi, +} from '@angular/common/http' +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { environment } from 'src/environments/environment' +import { ChatService } from './chat.service' + +describe('ChatService', () => { + let service: ChatService + let httpMock: HttpTestingController + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [], + providers: [ + ChatService, + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + ], + }) + service = TestBed.inject(ChatService) + httpMock = TestBed.inject(HttpTestingController) + }) + + afterEach(() => { + httpMock.verify() + }) + + it('should stream chat messages', (done) => { + const documentId = 1 + const prompt = 'Hello, world!' + const mockResponse = 'Partial response text' + const apiUrl = `${environment.apiBaseUrl}documents/chat/` + + service.streamChat(documentId, prompt).subscribe((chunk) => { + expect(chunk).toBe(mockResponse) + done() + }) + + const req = httpMock.expectOne(apiUrl) + expect(req.request.method).toBe('POST') + expect(req.request.body).toEqual({ + document_id: documentId, + q: prompt, + }) + + req.event({ + type: HttpEventType.DownloadProgress, + partialText: mockResponse, + } as any) + }) +}) diff --git a/src-ui/src/app/services/chat.service.ts b/src-ui/src/app/services/chat.service.ts index 5e576ceba..5c9307db6 100644 --- a/src-ui/src/app/services/chat.service.ts +++ b/src-ui/src/app/services/chat.service.ts @@ -20,7 +20,6 @@ export class ChatService { constructor(private http: HttpClient) {} streamChat(documentId: number, prompt: string): Observable { - // use httpclient as we have withFetch return this.http .post( `${environment.apiBaseUrl}documents/chat/`,