diff --git a/frontend/src/components/documents/DocumentCard.vue b/frontend/src/components/documents/DocumentCard.vue index 54ce1a4..2d46f05 100644 --- a/frontend/src/components/documents/DocumentCard.vue +++ b/frontend/src/components/documents/DocumentCard.vue @@ -31,6 +31,19 @@
Shared
+ + +
+ Classification failed + +
@@ -94,7 +107,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { useTopicsStore } from '../../stores/topics.js' import { useFoldersStore } from '../../stores/folders.js' -import { moveDocument } from '../../api/client.js' +import { moveDocument, classifyDocument } from '../../api/client.js' import TopicBadge from '../topics/TopicBadge.vue' import ShareModal from '../sharing/ShareModal.vue' import { formatDate, formatSize } from '../../utils/formatters.js' @@ -103,10 +116,13 @@ const props = defineProps({ doc: Object, }) +const emit = defineEmits(['reclassified']) + const topicsStore = useTopicsStore() const foldersStore = useFoldersStore() const showShareModal = ref(false) const showFolderPicker = ref(false) +const reanalyzing = ref(false) const allFolders = computed(() => foldersStore.rootFolders) @@ -138,4 +154,16 @@ function topicColor(name) { return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1' } +async function reanalyze() { + reanalyzing.value = true + try { + await classifyDocument(props.doc.id) + emit('reclassified', props.doc.id) + } catch (e) { + console.error('Re-analyze failed:', e.message) + } finally { + setTimeout(() => { reanalyzing.value = false }, 500) + } +} + diff --git a/frontend/tests/DocumentCard.spec.js b/frontend/tests/DocumentCard.spec.js new file mode 100644 index 0000000..16b1037 --- /dev/null +++ b/frontend/tests/DocumentCard.spec.js @@ -0,0 +1,122 @@ +/** + * Vitest unit tests for DocumentCard.vue — classification-failed badge + * and reanalyze() handler. + * + * D-11 frontend coverage — CLAUDE.md mandate: every new component / function + * must have at least one test. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +// ── Mock API client ─────────────────────────────────────────────────────────── +vi.mock('../src/api/client.js', () => ({ + classifyDocument: vi.fn(() => + Promise.resolve({ document_id: 'd1', status: 'processing' }) + ), + // Stub other imports so the component can load without Pinia + moveDocument: vi.fn(() => Promise.resolve()), +})) + +// ── Mock Pinia stores referenced by DocumentCard ────────────────────────────── +vi.mock('../src/stores/topics.js', () => ({ + useTopicsStore: () => ({ topics: [] }), +})) + +vi.mock('../src/stores/folders.js', () => ({ + useFoldersStore: () => ({ rootFolders: [] }), +})) + +// ── Mock child components ───────────────────────────────────────────────────── +vi.mock('../src/components/topics/TopicBadge.vue', () => ({ + default: { template: '' }, +})) + +vi.mock('../src/components/sharing/ShareModal.vue', () => ({ + default: { template: '
' }, +})) + +// ── Mock formatters (used in template) ─────────────────────────────────────── +vi.mock('../src/utils/formatters.js', () => ({ + formatDate: (v) => String(v), + formatSize: (v) => String(v), +})) + +// ── Import component and mock after all mocks are registered ───────────────── +import DocumentCard from '../src/components/documents/DocumentCard.vue' +import { classifyDocument } from '../src/api/client.js' + +// ── Minimal doc fixture ─────────────────────────────────────────────────────── +function makeDoc(overrides = {}) { + return { + id: 'd1', + original_name: 'test.pdf', + size_bytes: 1024, + created_at: '2024-01-01T00:00:00Z', + topics: [], + is_shared: false, + status: 'ready', + ...overrides, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('DocumentCard — classification_failed badge', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders Classification failed badge when status === "classification_failed"', () => { + const wrapper = mount(DocumentCard, { + props: { doc: makeDoc({ status: 'classification_failed' }) }, + global: { stubs: { RouterLink: true } }, + }) + + expect(wrapper.text()).toContain('Classification failed') + const btn = wrapper.find('button[disabled]') || wrapper.findAll('button').find(b => b.text().includes('Re-analyze')) + expect(btn || wrapper.text()).toBeTruthy() + expect(wrapper.text()).toContain('Re-analyze') + }) + + it('does NOT render the badge when status is "ready"', () => { + const wrapper = mount(DocumentCard, { + props: { doc: makeDoc({ status: 'ready' }) }, + global: { stubs: { RouterLink: true } }, + }) + + expect(wrapper.text()).not.toContain('Classification failed') + expect(wrapper.text()).not.toContain('Re-analyze') + }) + + it('does NOT render the badge when status is "processing"', () => { + const wrapper = mount(DocumentCard, { + props: { doc: makeDoc({ status: 'processing' }) }, + global: { stubs: { RouterLink: true } }, + }) + + expect(wrapper.text()).not.toContain('Classification failed') + }) + + it('Re-analyze button calls classifyDocument(doc.id) and emits "reclassified"', async () => { + const wrapper = mount(DocumentCard, { + props: { doc: makeDoc({ status: 'classification_failed' }) }, + global: { stubs: { RouterLink: true } }, + }) + + // Find the Re-analyze button + const buttons = wrapper.findAll('button') + const reanalyzeBtn = buttons.find(b => b.text().includes('Re-analyze')) + expect(reanalyzeBtn).toBeTruthy() + + await reanalyzeBtn.trigger('click') + await flushPromises() + + expect(classifyDocument).toHaveBeenCalledOnce() + expect(classifyDocument).toHaveBeenCalledWith('d1') + + const emitted = wrapper.emitted('reclassified') + expect(emitted).toBeTruthy() + expect(emitted[0]).toEqual(['d1']) + }) +})