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'])
+ })
+})