From 930d23956b8535a8600023eb95782b512a2fdf03 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:34:22 -0700 Subject: [PATCH] Up frontend coverage --- ...emplate-editor-zone-list.component.spec.ts | 6 + .../ocr-template-editor.component.spec.ts | 349 ++++++++++++++++++ .../ocr-template-editor/zone-geometry.spec.ts | 30 ++ .../ocr-templates.component.spec.ts | 13 + .../rest/ocr-template.service.spec.ts | 78 ++++ 5 files changed, 476 insertions(+) create mode 100644 src-ui/src/app/services/rest/ocr-template.service.spec.ts diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.spec.ts index 875566d96..b43156466 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.spec.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor-zone-list/ocr-template-editor-zone-list.component.spec.ts @@ -109,4 +109,10 @@ describe('OcrTemplateEditorZoneListComponent', () => { expect(selectSpy).toHaveBeenCalledWith(0) expect(removeSpy).toHaveBeenCalledWith(0) }) + + it('does not label an unknown built-in target', () => { + expect( + component.getZoneTargetName(zone({ target: 'unknown' as OcrZoneTarget })) + ).toBe('') + }) }) diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts index f0e447975..f2836ffd4 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/ocr-template-editor.component.spec.ts @@ -30,6 +30,7 @@ import { DocumentService } from 'src/app/services/rest/document.service' import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service' import { UserService } from 'src/app/services/rest/user.service' import { SettingsService } from 'src/app/services/settings.service' +import { ToastService } from 'src/app/services/toast.service' import { OcrTemplateEditorComponent } from './ocr-template-editor.component' const customFields: CustomField[] = [ @@ -129,6 +130,354 @@ describe('OcrTemplateEditorComponent', () => { component.pageLoaded.set(true) } + function fieldDialog() { + const dialog = { + dialogMode: { set: jest.fn() }, + object: null, + succeeded: new Subject(), + failed: new Subject(), + } + jest + .spyOn(modalService, 'open') + .mockReturnValue({ componentInstance: dialog } as any) + return dialog + } + + it('updates screen-sized overlay units when the preview image resizes', () => { + let resize: ResizeObserverCallback + const observer = { + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + } + const resizeObserver = jest + .spyOn(globalThis, 'ResizeObserver') + .mockImplementation((callback) => { + resize = callback + return observer + }) + const image = document.createElement('img') + const ref = new ElementRef(image) + component.pageImage = ref + expect(observer.observe).toHaveBeenCalledWith(image) + resize([], observer) + expect(component.overlayUnitSize(8)).toBe(8) + + Object.defineProperties(image, { + naturalWidth: { value: 1000 }, + clientWidth: { value: 500 }, + }) + resize([], observer) + expect(component.overlayUnitSize(8)).toBe(16) + expect(component.overlayFontSize()).toBe(24) + + component.pageImage = undefined + expect(observer.disconnect).toHaveBeenCalledTimes(1) + fixture.destroy() + expect(observer.disconnect).toHaveBeenCalledTimes(2) + resizeObserver.mockRestore() + }) + + it('renders loaded image dimensions, zone labels, and selection handles', () => { + component.object = { + ...component.template, + id: 3, + sample_document: 5, + zones: [zone()], + } + component.selectedZoneIndex = 0 + fixture.detectChanges() + const image = fixture.nativeElement.querySelector('img') + Object.defineProperties(image, { + naturalWidth: { value: 1000 }, + naturalHeight: { value: 1500 }, + }) + image.dispatchEvent(new Event('load')) + fixture.detectChanges() + + expect(component.template).toMatchObject({ + source_width: 1000, + source_height: 1500, + }) + expect( + fixture.nativeElement + .querySelector('.zone-overlay') + .getAttribute('viewBox') + ).toBe('0 0 1000 1500') + expect( + fixture.nativeElement.querySelector('.zone-label').textContent + ).toContain('Invoice number') + expect( + fixture.nativeElement.querySelectorAll('.zone-resize-handle') + ).toHaveLength(8) + expect(component.zoneLabel(zone({ name: '' }), 2)).toBe('Zone 3') + expect(component.zoneLabelY({ x: 0, y: 0, w: 100, h: 100 })).toBe(14) + }) + + it('offers the supported language and transform choices', () => { + expect(component.languageOptions.map((option) => option.id)).toContain( + 'eng' + ) + expect(component.transformOptions.map((option) => option.id)).toEqual( + Object.values(OcrZoneTransform) + ) + }) + + it('clamps page navigation and supports one-based page input', () => { + component.setSampleDocument([5]) + component.previewPageDisplay = 2 + expect(component.previewPage).toBe(1) + expect(component.previewPageDisplay).toBe(2) + expect(component.pageImageUrl).toContain('/5/1/') + component.pageLoaded.set(true) + component.goToPage(1) + expect(component.pageLoaded()).toBe(true) + component.goToPage(Infinity) + component.goToPage(NaN) + expect(component.previewPage).toBe(1) + component.goToPage(99) + expect(component.previewPage).toBe(2) + expect(component.pageLoaded()).toBe(false) + component.goToPage(-5) + expect(component.previewPage).toBe(0) + component.previewPageCount.set(null) + component.goToPage(4.7) + expect(component.previewPage).toBe(4) + }) + + it('limits zoom and resets it to the original size', () => { + for (let i = 0; i < 20; i++) component.zoomIn() + expect(component.zoom).toBe(4) + for (let i = 0; i < 20; i++) component.zoomOut() + expect(component.zoom).toBe(0.5) + component.resetZoom() + expect(component.zoom).toBe(1) + }) + + it('moves last-page zones to the last page and can pin them to a page', () => { + component.setSampleDocument([5]) + const selected = zone() + component.template.zones = [selected] + expect(component.isLastPageZone(selected)).toBe(false) + component.setLastPageZone(selected, true) + expect(selected.page).toBe(-1) + expect(component.isLastPageZone(selected)).toBe(true) + component.selectZone(0) + expect(component.previewPage).toBe(2) + component.setLastPageZone(selected, false) + expect(selected.page).toBe(3) + }) + + it('ignores page-count errors from a previous sample document', () => { + const pending = new Subject() + jest + .spyOn(documentService, 'get') + .mockReturnValueOnce(pending) + .mockReturnValueOnce(of({ id: 6 })) + component.setSampleDocument([5]) + component.setSampleDocument([6]) + pending.error(new Error('Old document failed')) + expect(component.pageFailed()).toBe(false) + expect(component.previewPageCount()).toBeNull() + }) + + it('ignores non-primary, unloaded, and saving pointer gestures', () => { + prepareOverlay() + component.onOverlayPointerDown( + new MouseEvent('pointerdown', { button: 2 }) as PointerEvent + ) + expect(component.drawingRect()).toBeNull() + component.pageLoaded.set(false) + component.onOverlayPointerDown(pointer(100, 100)) + expect(component.drawingRect()).toBeNull() + component.pageLoaded.set(true) + component.saving.set(true) + component.onOverlayPointerDown(pointer(100, 100)) + expect(component.drawingRect()).toBeNull() + }) + + it('ignores pointer events when the SVG coordinate transform is unavailable', () => { + component.onOverlayPointerDown(pointer(100, 100)) + expect(component.drawingRect()).toBeNull() + prepareOverlay() + jest + .spyOn(component.overlayRef.nativeElement, 'getScreenCTM') + .mockReturnValue(null) + component.onOverlayPointerDown(pointer(100, 100)) + component.onOverlayPointerMove(pointer(200, 200)) + expect(component.drawingRect()).toBeNull() + }) + + it('updates a drawing only for its captured pointer', () => { + prepareOverlay() + component.onOverlayPointerDown(pointer(100, 100)) + component.onOverlayPointerDown(pointer(500, 500, 2)) + component.onOverlayPointerMove(pointer(900, 900, 2)) + expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 0, h: 0 }) + component.onOverlayPointerMove(pointer(250, 200)) + expect(component.drawingRect()).toEqual({ x: 100, y: 100, w: 150, h: 100 }) + component.onOverlayPointerUp(pointer(250, 200)) + expect(component.template.zones[0]).toMatchObject({ + x: 100, + y: 100, + width: 150, + height: 100, + }) + }) + + it('resizes a selected zone using its corner handle', () => { + prepareOverlay() + const selected = zone() + component.template.zones = [selected] + component.selectZone(0) + component.onOverlayPointerDown(pointer(100, 100)) + component.onOverlayPointerMove(pointer(50, 75)) + component.onOverlayPointerUp(pointer(60, 80)) + expect(selected).toMatchObject({ x: 60, y: 80, width: 240, height: 120 }) + expect(component.template.zones).toHaveLength(1) + }) + + it('shows the appropriate cursor over handles, zones, and empty space', () => { + prepareOverlay() + component.template.zones = [zone()] + component.onOverlayPointerMove(pointer(150, 150)) + expect(component.overlayCursor).toBe('move') + component.selectZone(0) + component.onOverlayPointerMove(pointer(100, 100)) + expect(component.overlayCursor).toBe('nw-resize') + component.onOverlayPointerMove(pointer(500, 500)) + expect(component.overlayCursor).toBe('crosshair') + component.template.zones[0].page = 2 + component.onOverlayPointerMove(pointer(100, 100)) + expect(component.zoneDisplayRect(0)).toBeNull() + expect(component.overlayCursor).toBe('crosshair') + }) + + it('cancels only the captured pointer and tolerates an already-lost capture', () => { + prepareOverlay() + component.onOverlayPointerCancel(pointer(100, 100)) + component.onOverlayPointerDown(pointer(100, 100)) + component.onOverlayPointerCancel(pointer(150, 150, 2)) + expect(component.drawingRect()).not.toBeNull() + jest + .spyOn(component.overlayRef.nativeElement, 'hasPointerCapture') + .mockReturnValue(false) + component.onOverlayPointerCancel(pointer(150, 150)) + expect(component.drawingRect()).toBeNull() + expect( + component.overlayRef.nativeElement.releasePointerCapture + ).not.toHaveBeenCalled() + expect(component.template.zones).toEqual([]) + }) + + it('preserves the selected zone when an earlier or later zone is removed', () => { + component.template.zones = [zone(), zone(), zone(), zone()] + component.selectZone(1) + const selected = component.selectedZone + component.removeZone(3) + expect(component.selectedZone).toBe(selected) + component.removeZone(0) + expect(component.selectedZoneIndex).toBe(0) + expect(component.selectedZone).toBe(selected) + component.selectZone(99) + expect(component.selectedZone).toBeNull() + }) + + it('does not test zones without a selection or a sample document', () => { + const test = jest.spyOn(templateService, 'testZone') + component.testZone() + component.template.zones = [zone()] + component.selectZone(0) + component.testZone() + expect(test).not.toHaveBeenCalled() + }) + + it.each([ + { + response: { error: { error: 'OCR unavailable' } }, + message: 'OCR unavailable', + }, + { response: new Error('Network failure'), message: 'Test failed' }, + ])('shows zone-test errors: $message', ({ response, message }) => { + component.template.zones = [zone({ page: undefined })] + component.selectZone(0) + component.setSampleDocument([5]) + jest + .spyOn(templateService, 'testZone') + .mockReturnValue(throwError(() => response)) + component.testZone() + expect(component.zoneTestResult).toEqual({ error: message }) + expect(component.zoneTesting).toBe(false) + expect(templateService.testZone).toHaveBeenCalledWith( + 5, + expect.objectContaining({ page: 1 }) + ) + }) + + it('switches between built-in targets, custom fields, and an unassigned field', () => { + const selected = zone() + expect(component.zoneFieldValue(selected)).toBe(1) + component.setZoneField(selected, OcrZoneTarget.Title) + expect(component.zoneFieldValue(selected)).toBe(OcrZoneTarget.Title) + expect(selected.custom_field).toBeNull() + component.setZoneField(selected, 7) + expect(selected).toMatchObject({ + target: OcrZoneTarget.CustomField, + custom_field: 7, + }) + component.setZoneField(selected, null) + expect(component.zoneFieldValue(selected)).toBeNull() + expect(component.zonesForField(selected)).toEqual([]) + component.setCombineFormat(selected, 'Ignored') + expect(component.template.combine_formats).toEqual({}) + }) + + it('creates combine formats when absent and leaves implicit formats alone on rename', () => { + const selected = zone({ target: OcrZoneTarget.Title, custom_field: null }) + component.template.combine_formats = undefined + component.renameZone(selected, 'Renamed') + expect(selected.name).toBe('Renamed') + expect(component.getCombineFormat(selected)).toBe('') + component.setCombineFormat(selected, '{Renamed} ') + component.insertCombineToken(selected, selected) + expect(component.template.combine_formats).toEqual({ + title: '{Renamed} {Renamed}', + }) + }) + + it('saves templates without combine formats', () => { + component.template.combine_formats = undefined + fixture.detectChanges() + expect(component.isDirty).toBe(false) + jest + .spyOn(templateService, 'create') + .mockReturnValue(of(component.template)) + component.save() + expect(component.template.combine_formats).toBeUndefined() + }) + + it('does not assign unsupported custom field types and surfaces creation failures', () => { + component.template.zones = [zone({ custom_field: null })] + component.selectZone(0) + const dialog = fieldDialog() + const error = jest.spyOn(TestBed.inject(ToastService), 'showError') + component.createField('Status') + expect(dialog.object).toEqual({ name: 'Status' }) + dialog.succeeded.next(customFields[1]) + expect(component.selectedZone.custom_field).toBeNull() + const failure = new Error('Could not save') + dialog.failed.next(failure) + expect(error).toHaveBeenCalledWith('Error saving field.', failure) + }) + + it('refreshes field choices when a field is created without a selected zone', () => { + const dialog = fieldDialog() + component.createField('Total') + dialog.succeeded.next(customFields[0]) + expect(component.customFields()).toEqual(customFields) + expect(component.selectedZone).toBeNull() + }) + it('should edit a copy of the template and load its sample document', () => { const original: OcrTemplate = { ...component.template, diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.spec.ts index 573e426e4..63851072f 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.spec.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-template-editor/zone-geometry.spec.ts @@ -95,6 +95,36 @@ describe('OCR template editor geometry', () => { expect(findHandleAt({ x: 19, y: 20 }, rect, 10)).toBe('nw') }) + it('returns no zone when the point is outside every rectangle', () => { + expect( + findZoneAt({ x: 900, y: 900 }, [zone()], 0, 1, { + width: 1000, + height: 1000, + }) + ).toBeNull() + }) + + it('resizes north-west edges while keeping the opposite corner fixed', () => { + const z = zone({ x: 100, y: 200, width: 300, height: 400 }) + resizeZone(z, 'nw', { x: 50, y: 100 }, { width: 1000, height: 1000 }) + expect(z).toMatchObject({ x: 50, y: 100, width: 350, height: 500 }) + resizeZone(z, 'nw', { x: 900, y: 900 }, { width: 1000, height: 1000 }) + expect(z).toMatchObject({ x: 390, y: 590, width: 10, height: 10 }) + }) + + it('clamps resized north-west edges to the source image bounds', () => { + const z = zone({ + x: 50, + y: 50, + width: 100, + height: 100, + zone_source_width: 200, + zone_source_height: 300, + }) + resizeZone(z, 'nw', { x: -50, y: -50 }, { width: 400, height: 600 }) + expect(z).toMatchObject({ x: 0, y: 0, width: 150, height: 150 }) + }) + it('uses the rendered handle positions for hit testing', () => { const rect = { x: 10, y: 20, w: 100, h: 200 } const handles = getResizeHandles(rect) diff --git a/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts index 5d04a58a8..963aa132d 100644 --- a/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts +++ b/src-ui/src/app/components/manage/ocr-templates/ocr-templates.component.spec.ts @@ -284,4 +284,17 @@ describe('OcrTemplatesComponent', () => { expect(toastSuccessSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled() }) + + it.each([true, false])( + 'reports the enabled state %s after the update succeeds', + (enabled) => { + const template = { ...templates[0], enabled } + jest.spyOn(ocrTemplateService, 'patch').mockReturnValue(of(template)) + const toast = jest.spyOn(toastService, 'showSuccess') + component.toggleTemplateEnabled(template) + expect(toast).toHaveBeenCalledWith( + `${enabled ? 'Enabled' : 'Disabled'} OCR template "${template.name}"` + ) + } + ) }) diff --git a/src-ui/src/app/services/rest/ocr-template.service.spec.ts b/src-ui/src/app/services/rest/ocr-template.service.spec.ts new file mode 100644 index 000000000..84a2f3f5f --- /dev/null +++ b/src-ui/src/app/services/rest/ocr-template.service.spec.ts @@ -0,0 +1,78 @@ +import { provideHttpClient } from '@angular/common/http' +import { + HttpTestingController, + provideHttpClientTesting, +} from '@angular/common/http/testing' +import { TestBed } from '@angular/core/testing' +import { OcrZoneTransform, ZoneTestRequest } from 'src/app/data/ocr-template' +import { environment } from 'src/environments/environment' +import { OcrTemplateService } from './ocr-template.service' + +describe('OcrTemplateService', () => { + let service: OcrTemplateService + let http: HttpTestingController + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }) + service = TestBed.inject(OcrTemplateService) + http = TestBed.inject(HttpTestingController) + }) + + afterEach(() => http.verify()) + + it('builds page image URLs with zero-based page numbers', () => { + expect(service.getPageImageUrl(12, 0)).toBe( + `${environment.apiBaseUrl}ocr_templates/document-page-image/12/0/` + ) + expect(service.getPageImageUrl(12, 2)).toBe( + `${environment.apiBaseUrl}ocr_templates/document-page-image/12/2/` + ) + }) + + it('posts a zone test and returns the extraction result', () => { + const zone: ZoneTestRequest = { + name: 'Total', + x: 10, + y: 20, + width: 100, + height: 50, + page: -1, + ocr_language: 'eng', + transform: OcrZoneTransform.Numeric, + validation_regex: '\\d+', + zone_source_width: 1000, + zone_source_height: 1500, + } + const result = { + raw_text: 'Total: 123', + value: '123', + regex: '\\d+', + regex_match: true, + } + const next = jest.fn() + service.testZone(12, zone).subscribe(next) + const request = http.expectOne( + `${environment.apiBaseUrl}ocr_templates/test-zone/` + ) + expect(request.request.method).toBe('POST') + expect(request.request.body).toEqual({ document: 12, zone }) + request.flush(result) + expect(next).toHaveBeenCalledWith(result) + }) + + it('passes zone-test failures to the caller', () => { + const error = jest.fn() + service.testZone(12, {} as ZoneTestRequest).subscribe({ error }) + http + .expectOne(`${environment.apiBaseUrl}ocr_templates/test-zone/`) + .flush( + { error: 'Invalid zone' }, + { status: 400, statusText: 'Bad Request' } + ) + expect(error).toHaveBeenCalledWith( + expect.objectContaining({ status: 400, error: { error: 'Invalid zone' } }) + ) + }) +})