diff --git a/vigilcare-records-web/src/__tests__/components/EmptyLoadingError.test.ts b/vigilcare-records-web/src/__tests__/components/EmptyLoadingError.test.ts new file mode 100644 index 0000000..db8bed4 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/EmptyLoadingError.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import EmptyState from '@/components/EmptyState.vue' +import SkeletonBlock from '@/components/SkeletonBlock.vue' +import InlineError from '@/components/InlineError.vue' +import BatchList from '@/components/BatchList.vue' +import type { BatchDetailResponse } from '@/types' + +function makeBatch(overrides: Partial = {}): BatchDetailResponse { + return { + id: 'batch-aaaaaaaa', + status: 'UPLOADED', + batchType: 'VITALS', + track: 'TRACK_A', + createdAt: '2026-01-15T10:00:00Z', + ...overrides, + } as BatchDetailResponse +} + +describe('EmptyState', () => { + it('renders operational title and description', () => { + const wrapper = mount(EmptyState, { + props: { + title: 'No batches are waiting for verification.', + description: 'New batches appear here after data entry is submitted.', + }, + }) + expect(wrapper.text()).toContain('No batches are waiting for verification.') + expect(wrapper.text()).toContain('New batches appear here after data entry is submitted.') + }) + + it('renders action slot', () => { + const wrapper = mount(EmptyState, { + props: { title: 'Empty' }, + slots: { action: '' }, + }) + expect(wrapper.find('button').text()).toBe('Return to Dashboard') + }) +}) + +describe('SkeletonBlock', () => { + it('exposes loading status for table variant', () => { + const wrapper = mount(SkeletonBlock, { props: { variant: 'table', rows: 3 } }) + expect(wrapper.attributes('role')).toBe('status') + expect(wrapper.attributes('aria-busy')).toBe('true') + }) +}) + +describe('InlineError', () => { + it('shows what happened, what was preserved, and retry', async () => { + const wrapper = mount(InlineError, { + props: { + title: 'Could not load batches', + message: 'Network error', + preserved: 'Your filters were preserved.', + retryLabel: 'Retry', + }, + }) + expect(wrapper.text()).toContain('Could not load batches') + expect(wrapper.text()).toContain('Network error') + expect(wrapper.text()).toContain('Your filters were preserved.') + await wrapper.find('button').trigger('click') + expect(wrapper.emitted('retry')).toHaveLength(1) + }) +}) + +describe('BatchList patterns', () => { + it('shows skeleton while loading', () => { + const wrapper = mount(BatchList, { + props: { batches: [], loading: true }, + }) + expect(wrapper.find('[role="status"]').exists()).toBe(true) + expect(wrapper.text()).not.toContain('No batches') + }) + + it('shows empty state when idle with no batches', () => { + const wrapper = mount(BatchList, { + props: { + batches: [], + loading: false, + emptyTitle: 'No batches are waiting for verification.', + emptyDescription: 'New batches appear here after data entry is submitted.', + }, + }) + expect(wrapper.text()).toContain('No batches are waiting for verification.') + }) + + it('shows inline error with retry over empty/loading', async () => { + const wrapper = mount(BatchList, { + props: { + batches: [], + loading: false, + error: 'Timed out', + }, + }) + expect(wrapper.text()).toContain('Could not load batches') + expect(wrapper.text()).toContain('Timed out') + await wrapper.find('button').trigger('click') + expect(wrapper.emitted('retry')).toHaveLength(1) + }) + + it('renders StatusBadge for each batch status', () => { + const wrapper = mount(BatchList, { + props: { + batches: [ + makeBatch({ id: 'b1', status: 'PENDING_VERIFICATION' }), + makeBatch({ id: 'b2', status: 'PROMOTED' }), + ], + loading: false, + }, + }) + expect(wrapper.text()).toContain('Pending Verification') + expect(wrapper.text()).toContain('Promoted') + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts b/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts index 2abe151..266c0d6 100644 --- a/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts +++ b/vigilcare-records-web/src/__tests__/components/EntryForm.test.ts @@ -116,7 +116,7 @@ describe('EntryForm', () => { const wrapper = mount(EntryForm, { props: { batch: makeBatch({ status: 'IN_ENTRY' }), batchId: 'b1' }, }) - expect(wrapper.text()).toContain('IN ENTRY') + expect(wrapper.text()).toContain('In Entry') }) describe('conditional sections by batch type', () => { diff --git a/vigilcare-records-web/src/__tests__/components/SeparationOfDutiesBanner.test.ts b/vigilcare-records-web/src/__tests__/components/SeparationOfDutiesBanner.test.ts new file mode 100644 index 0000000..d8aad36 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/SeparationOfDutiesBanner.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import SeparationOfDutiesBanner from '@/components/SeparationOfDutiesBanner.vue' +import ConfirmDialog from '@/components/ConfirmDialog.vue' + +describe('SeparationOfDutiesBanner', () => { + it('shows enforced info when entered-by and verifier differ', () => { + const wrapper = mount(SeparationOfDutiesBanner, { + props: { + enteredByUserId: 'entry-user-1', + enteredByUserName: 'Arjun Menon', + currentUserId: 'verify-user-2', + currentUserName: 'Priya Nair', + }, + }) + + expect(wrapper.text()).toContain('Separation of Duties Enforced') + expect(wrapper.text()).toContain('Arjun Menon') + expect(wrapper.text()).toContain('Priya Nair') + expect(wrapper.text()).not.toContain('You cannot verify') + expect(wrapper.emitted('update:blocked')?.at(-1)).toEqual([false]) + }) + + it('blocks when the same user entered the batch', () => { + const wrapper = mount(SeparationOfDutiesBanner, { + props: { + enteredByUserId: 'same-user', + currentUserId: 'same-user', + currentUserName: 'Alex Clerk', + }, + }) + + expect(wrapper.text()).toContain('You cannot verify a batch you entered.') + expect(wrapper.emitted('update:blocked')?.at(-1)).toEqual([true]) + }) + + it('falls back to truncated ids when names are omitted', () => { + const wrapper = mount(SeparationOfDutiesBanner, { + props: { + enteredByUserId: 'abcdefghijkl', + currentUserId: 'mnopqrstuvwx', + }, + }) + + expect(wrapper.text()).toContain('abcdefgh…') + expect(wrapper.text()).toContain('mnopqrst…') + }) + + it('hides when either id is missing', () => { + const wrapper = mount(SeparationOfDutiesBanner, { + props: { + enteredByUserId: 'entry-1', + currentUserId: '', + }, + }) + expect(wrapper.find('[data-testid="sod-banner"]').exists()).toBe(false) + }) +}) + +describe('ConfirmDialog', () => { + const teleportStub = { + global: { + stubs: { + Teleport: { template: '
' }, + }, + }, + } + + it('renders title, body, and confirm label when open', () => { + const wrapper = mount(ConfirmDialog, { + props: { + open: true, + title: 'Approve & Promote', + body: 'Approval will promote the verified records into live clinical tables.', + confirmLabel: 'Approve & Promote', + variant: 'primary', + }, + ...teleportStub, + }) + + expect(wrapper.text()).toContain('Approve & Promote') + expect(wrapper.text()).toContain( + 'Approval will promote the verified records into live clinical tables.' + ) + }) + + it('emits confirm and cancel', async () => { + const wrapper = mount(ConfirmDialog, { + props: { + open: true, + title: 'Reject Batch', + confirmLabel: 'Confirm Rejection', + variant: 'danger', + }, + ...teleportStub, + }) + + const buttons = wrapper.findAll('button') + const confirmBtn = buttons.find((b) => b.text() === 'Confirm Rejection') + const cancelBtn = buttons.find((b) => b.text() === 'Cancel') + await confirmBtn!.trigger('click') + await cancelBtn!.trigger('click') + expect(wrapper.emitted('confirm')).toHaveLength(1) + expect(wrapper.emitted('cancel')).toHaveLength(1) + }) + + it('disables confirm when confirmDisabled is true', () => { + const wrapper = mount(ConfirmDialog, { + props: { + open: true, + title: 'Reject Batch', + confirmLabel: 'Confirm Rejection', + variant: 'danger', + confirmDisabled: true, + }, + ...teleportStub, + }) + + const confirmBtn = wrapper + .findAll('button') + .find((b) => b.text() === 'Confirm Rejection') + expect(confirmBtn!.attributes('disabled')).toBeDefined() + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/StatusBadge.test.ts b/vigilcare-records-web/src/__tests__/components/StatusBadge.test.ts new file mode 100644 index 0000000..6029631 --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/StatusBadge.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import StatusBadge from '@/components/StatusBadge.vue' +import { BATCH_STATUS_META, getBatchStatusMeta } from '@/utils/batchStatus' + +describe('StatusBadge', () => { + it.each(Object.keys(BATCH_STATUS_META))('renders label and icon for %s', (status) => { + const wrapper = mount(StatusBadge, { props: { status } }) + expect(wrapper.text()).toContain(BATCH_STATUS_META[status].label) + expect(wrapper.find('svg').exists()).toBe(true) + }) + + it('humanizes unknown statuses without relying on color alone', () => { + const wrapper = mount(StatusBadge, { props: { status: 'CUSTOM_STATE' } }) + expect(wrapper.text()).toContain('Custom State') + expect(wrapper.find('svg').exists()).toBe(true) + }) + + it('handles null status', () => { + const wrapper = mount(StatusBadge, { props: { status: null } }) + expect(wrapper.text()).toContain('Unknown') + }) +}) + +describe('getBatchStatusMeta', () => { + it('maps REJECTED to Verification Rejected', () => { + expect(getBatchStatusMeta('REJECTED').label).toBe('Verification Rejected') + }) + + it('maps AWAITING_CLINICAL_APPROVAL to Pending Approval', () => { + expect(getBatchStatusMeta('AWAITING_CLINICAL_APPROVAL').label).toBe('Pending Approval') + }) +}) diff --git a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts index 0a666a5..08ef2c3 100644 --- a/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts +++ b/vigilcare-records-web/src/__tests__/components/VerificationForm.test.ts @@ -3,6 +3,7 @@ import { mount, flushPromises } from '@vue/test-utils' import { setActivePinia, createPinia } from 'pinia' import VerificationForm from '@/components/VerificationForm.vue' import { useBatchStore } from '@/stores/batches' +import { useAuthStore } from '@/stores/auth' import type { BatchDetailResponse } from '@/types' import { emptyDraft, @@ -31,8 +32,44 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn(), }), + useRoute: () => ({ + params: {}, + query: {}, + }), + createRouter: () => ({ + beforeEach: vi.fn(), + afterEach: vi.fn(), + push: vi.fn(), + replace: vi.fn(), + }), + createWebHistory: () => ({}), })) +vi.mock('@/router', () => ({ + default: { + push: vi.fn(), + replace: vi.fn(), + beforeEach: vi.fn(), + }, +})) + +const mountOptions = { + global: { + stubs: { + // Render dialog content in-tree (ConfirmDialog uses Teleport) + Teleport: { template: '
' }, + }, + }, +} + +function mountForm( + props: { batch: BatchDetailResponse | null; batchId: string } = { + batch: makeBatch(), + batchId: 'b1', + }, +) { + return mount(VerificationForm, { props, ...mountOptions }) +} function makeBatch(overrides: Partial = {}): BatchDetailResponse { const batchType = overrides.batchType ?? 'VITALS' return { @@ -63,9 +100,7 @@ function makeBatch(overrides: Partial = {}): BatchDetailRes function mountWithDraft(batchOverrides: Partial = {}) { const batch = makeBatch(batchOverrides) - const wrapper = mount(VerificationForm, { - props: { batch, batchId: 'b1' }, - }) + const wrapper = mountForm({ batch, batchId: 'b1' }) const store = useBatchStore() store.currentDraft = emptyDraft(batch.batchType, { @@ -123,9 +158,7 @@ beforeEach(() => { describe('VerificationForm', () => { it('renders verification header', () => { - const wrapper = mount(VerificationForm, { - props: { batch: makeBatch(), batchId: 'b1' }, - }) + const wrapper = mountForm() expect(wrapper.text()).toContain('Verification Review') expect(wrapper.text()).toContain('Pending Verification') }) @@ -153,20 +186,13 @@ describe('VerificationForm', () => { }) it('shows rejection reason banner when present', () => { - const wrapper = mount(VerificationForm, { - props: { - batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }), - batchId: 'b1', - }, - }) + const wrapper = mountForm({ batch: makeBatch({ rejectionReason: 'Temperature seems incorrect' }), batchId: 'b1' }) expect(wrapper.text()).toContain('Previous Rejection Reason') expect(wrapper.text()).toContain('Temperature seems incorrect') }) it('does not show rejection banner when no reason', () => { - const wrapper = mount(VerificationForm, { - props: { batch: makeBatch({ rejectionReason: null }), batchId: 'b1' }, - }) + const wrapper = mountForm({ batch: makeBatch({ rejectionReason: null }), batchId: 'b1' }) expect(wrapper.text()).not.toContain('Previous Rejection Reason') }) @@ -242,9 +268,7 @@ describe('VerificationForm', () => { describe('reject flow', () => { it('shows reject dialog when reject button is clicked', async () => { - const wrapper = mount(VerificationForm, { - props: { batch: makeBatch(), batchId: 'b1' }, - }) + const wrapper = mountForm() const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await rejectBtn!.trigger('click') @@ -255,9 +279,7 @@ describe('VerificationForm', () => { }) it('disables confirm button when reason is empty', async () => { - const wrapper = mount(VerificationForm, { - props: { batch: makeBatch(), batchId: 'b1' }, - }) + const wrapper = mountForm() const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await rejectBtn!.trigger('click') @@ -268,9 +290,7 @@ describe('VerificationForm', () => { }) it('enables confirm button when reason is entered', async () => { - const wrapper = mount(VerificationForm, { - props: { batch: makeBatch(), batchId: 'b1' }, - }) + const wrapper = mountForm() const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await rejectBtn!.trigger('click') @@ -285,9 +305,7 @@ describe('VerificationForm', () => { }) it('calls rejectBatch on confirm', async () => { - const wrapper = mount(VerificationForm, { - props: { batch: makeBatch(), batchId: 'b1' }, - }) + const wrapper = mountForm() const store = useBatchStore() store.rejectBatch = vi.fn().mockResolvedValue(undefined) @@ -308,9 +326,7 @@ describe('VerificationForm', () => { }) it('closes reject dialog on cancel', async () => { - const wrapper = mount(VerificationForm, { - props: { batch: makeBatch(), batchId: 'b1' }, - }) + const wrapper = mountForm() const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') await rejectBtn!.trigger('click') @@ -326,6 +342,64 @@ describe('VerificationForm', () => { }) }) + describe('separation of duties', () => { + it('shows SoD enforced banner and keeps Pass enabled for a different verifier', async () => { + const auth = useAuthStore() + auth.user = { + id: 'verifier-2', + username: 'verifier', + fullName: 'Priya Nair', + role: 'VERIFIER', + } + + const { wrapper } = mountWithDraft({ enteredByUserId: 'entry-1' }) + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Separation of Duties Enforced') + expect(wrapper.text()).toContain('Priya Nair') + + const checkboxes = wrapper.findAll('input[type="checkbox"]') + for (const cb of checkboxes) { + await cb.setValue(true) + } + await wrapper.vm.$nextTick() + + const approveBtn = wrapper + .findAll('button') + .find((b) => b.text().includes('Approve - Verified')) + expect(approveBtn!.element.disabled).toBe(false) + }) + + it('blocks Pass when the current user entered the batch', async () => { + const auth = useAuthStore() + auth.user = { + id: 'entry-1', + username: 'clerk', + fullName: 'Alex Clerk', + role: 'VERIFIER', + } + + const { wrapper } = mountWithDraft({ enteredByUserId: 'entry-1' }) + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('You cannot verify a batch you entered.') + + const checkboxes = wrapper.findAll('input[type="checkbox"]') + for (const cb of checkboxes) { + await cb.setValue(true) + } + await wrapper.vm.$nextTick() + + const approveBtn = wrapper + .findAll('button') + .find((b) => b.text().includes('Approve - Verified')) + expect(approveBtn!.element.disabled).toBe(true) + + const rejectBtn = wrapper.findAll('button').find((b) => b.text() === 'Reject') + expect(rejectBtn!.element.disabled).toBe(true) + }) + }) + describe('observations display', () => { it('shows observation count in legend', async () => { const { wrapper } = mountWithDraft() @@ -353,8 +427,9 @@ describe('VerificationForm', () => { }) it('shows NKA for noKnownAllergies', async () => { - const wrapper = mount(VerificationForm, { - props: { batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), batchId: 'b1' }, + const wrapper = mountForm({ + batch: makeBatch({ batchType: 'ALLERGY_UPDATE' }), + batchId: 'b1', }) const store = useBatchStore() diff --git a/vigilcare-records-web/src/__tests__/components/WorkstationActionBar.test.ts b/vigilcare-records-web/src/__tests__/components/WorkstationActionBar.test.ts new file mode 100644 index 0000000..52ad80d --- /dev/null +++ b/vigilcare-records-web/src/__tests__/components/WorkstationActionBar.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import { computed, ref } from 'vue' +import WorkstationActionBar from '@/components/WorkstationActionBar.vue' +import OcrConfidenceBadge from '@/components/OcrConfidenceBadge.vue' +import { + confidenceToLevel, + formatOcrBadgeLabel, + useOcrFieldConfidence, + OCR_HIGH_THRESHOLD, + OCR_MEDIUM_THRESHOLD, +} from '@/composables/useOcrFieldConfidence' +import type { OcrConfidenceMap } from '@/types' + +describe('WorkstationActionBar', () => { + it('renders sticky bar with left, center, primary, and right slots', () => { + const wrapper = mount(WorkstationActionBar, { + slots: { + left: '', + center: '', + primary: '', + right: '', + }, + }) + + const bar = wrapper.find('[data-testid="workstation-action-bar"]') + expect(bar.exists()).toBe(true) + expect(bar.classes()).toContain('sticky') + expect(bar.classes()).toContain('bottom-0') + expect(wrapper.text()).toContain('Reject') + expect(wrapper.text()).toContain('Save Draft') + expect(wrapper.text()).toContain('Submit for Verification') + expect(wrapper.text()).toContain('Next') + }) +}) + +describe('OCR confidence thresholds (design-doc §14)', () => { + it('maps 95%+ to high, 80–94% to medium, below 80% to low', () => { + expect(confidenceToLevel(0.95)).toBe('high') + expect(confidenceToLevel(1)).toBe('high') + expect(confidenceToLevel(0.94)).toBe('medium') + expect(confidenceToLevel(OCR_MEDIUM_THRESHOLD)).toBe('medium') + expect(confidenceToLevel(0.79)).toBe('low') + expect(OCR_HIGH_THRESHOLD).toBe(0.95) + expect(OCR_MEDIUM_THRESHOLD).toBe(0.8) + }) + + it('formats badge labels as OCR N%', () => { + expect(formatOcrBadgeLabel(0.98)).toBe('OCR 98%') + expect(formatOcrBadgeLabel(0.8)).toBe('OCR 80%') + }) + + it('exposes confidence, label, level, and border class from composable', () => { + const map = ref({ + provider: 'test', + fieldConfidences: { + 'patient.fullName': 0.98, + 'patient.sex': 0.85, + 'encounter.roomBed': 0.5, + }, + }) + const { + getFieldConfidence, + fieldConfidenceLabel, + fieldConfidenceLevel, + fieldConfidenceClass, + } = useOcrFieldConfidence(computed(() => map.value)) + + expect(getFieldConfidence('patient.fullName')).toBe(0.98) + expect(fieldConfidenceLabel('patient.fullName')).toBe('OCR 98%') + expect(fieldConfidenceLevel('patient.fullName')).toBe('high') + expect(fieldConfidenceClass('patient.fullName')).toBe('ocr-high') + + expect(fieldConfidenceLevel('patient.sex')).toBe('medium') + expect(fieldConfidenceClass('patient.sex')).toBe('ocr-medium') + + expect(fieldConfidenceLevel('encounter.roomBed')).toBe('low') + expect(fieldConfidenceClass('encounter.roomBed')).toBe('ocr-low') + + expect(fieldConfidenceLabel('missing.path')).toBeNull() + expect(fieldConfidenceClass('missing.path')).toBe('') + }) +}) + +describe('OcrConfidenceBadge', () => { + it('renders OCR percentage badge from confidence', () => { + const wrapper = mount(OcrConfidenceBadge, { props: { confidence: 0.98 } }) + expect(wrapper.text()).toBe('OCR 98%') + expect(wrapper.classes()).toContain('ocr-badge-high') + }) + + it('hides when confidence is missing', () => { + const wrapper = mount(OcrConfidenceBadge, { props: { confidence: null } }) + expect(wrapper.find('span').exists()).toBe(false) + }) + + it('uses provided label and level', () => { + const wrapper = mount(OcrConfidenceBadge, { + props: { label: 'OCR 72%', level: 'low' }, + }) + expect(wrapper.text()).toBe('OCR 72%') + expect(wrapper.classes()).toContain('ocr-badge-low') + }) +}) diff --git a/vigilcare-records-web/src/assets/main.css b/vigilcare-records-web/src/assets/main.css index 4b76e53..5e70e4a 100644 --- a/vigilcare-records-web/src/assets/main.css +++ b/vigilcare-records-web/src/assets/main.css @@ -100,4 +100,16 @@ .ocr-low { @apply border-l-[3px] border-l-clinical-danger; } + .ocr-confidence-badge { + @apply inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase leading-none; + } + .ocr-badge-high { + @apply bg-clinical-safe-bg text-clinical-safe; + } + .ocr-badge-medium { + @apply bg-clinical-warning-bg text-clinical-warning; + } + .ocr-badge-low { + @apply bg-clinical-danger-bg text-clinical-danger; + } } diff --git a/vigilcare-records-web/src/components/BatchList.vue b/vigilcare-records-web/src/components/BatchList.vue index 651ec1b..d81b964 100644 --- a/vigilcare-records-web/src/components/BatchList.vue +++ b/vigilcare-records-web/src/components/BatchList.vue @@ -1,12 +1,26 @@