mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-05-23 12:58:18 -05:00
Chat component and service coverage
This commit is contained in:
parent
0f730ee0a9
commit
b58c429c49
132
src-ui/src/app/components/chat/chat/chat.component.spec.ts
Normal file
132
src-ui/src/app/components/chat/chat/chat.component.spec.ts
Normal file
@ -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<ChatComponent>
|
||||||
|
let chatService: ChatService
|
||||||
|
let router: Router
|
||||||
|
let routerEvents$: Subject<NavigationEnd>
|
||||||
|
let mockStream$: Subject<string>
|
||||||
|
|
||||||
|
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<any>()
|
||||||
|
jest
|
||||||
|
.spyOn(router, 'events', 'get')
|
||||||
|
.mockReturnValue(routerEvents$.asObservable())
|
||||||
|
chatService = TestBed.inject(ChatService)
|
||||||
|
mockStream$ = new Subject<string>()
|
||||||
|
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<HTMLInputElement>
|
||||||
|
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
@ -45,9 +45,11 @@ export class ChatComponent implements OnInit {
|
|||||||
this.router.events
|
this.router.events
|
||||||
.pipe(
|
.pipe(
|
||||||
filter((event) => event instanceof NavigationEnd),
|
filter((event) => event instanceof NavigationEnd),
|
||||||
map(() => this.router.url)
|
map((event) => (event as NavigationEnd).url)
|
||||||
)
|
)
|
||||||
.subscribe((url) => {
|
.subscribe((url) => {
|
||||||
|
console.log('URL changed:', url)
|
||||||
|
|
||||||
this.updateDocumentId(url)
|
this.updateDocumentId(url)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -138,12 +140,5 @@ export class ChatComponent implements OnInit {
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
this.sendMessage()
|
this.sendMessage()
|
||||||
}
|
}
|
||||||
// } else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
|
|
||||||
// if (this.query?.length) {
|
|
||||||
// this.reset(true)
|
|
||||||
// } else {
|
|
||||||
// this.searchInput.nativeElement.blur()
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
58
src-ui/src/app/services/chat.service.spec.ts
Normal file
58
src-ui/src/app/services/chat.service.spec.ts
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
@ -20,7 +20,6 @@ export class ChatService {
|
|||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
streamChat(documentId: number, prompt: string): Observable<string> {
|
streamChat(documentId: number, prompt: string): Observable<string> {
|
||||||
// use httpclient as we have withFetch
|
|
||||||
return this.http
|
return this.http
|
||||||
.post(
|
.post(
|
||||||
`${environment.apiBaseUrl}documents/chat/`,
|
`${environment.apiBaseUrl}documents/chat/`,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user