feat(07-05): DocumentCard classification_failed badge + Re-analyze button + Vitest coverage
- Add red "Classification failed" pill badge when doc.status === 'classification_failed' - Re-analyze button calls classifyDocument(doc.id) via @click.stop, emits 'reclassified' - Spinner text "Re-analyzing…" while in flight; resets after 500ms (via setTimeout) - Existing is_shared badge block preserved unchanged - Create frontend/tests/DocumentCard.spec.js: 4 Vitest tests covering badge render, no-badge for ready/processing, and reanalyze() calling classifyDocument + emit - 131 frontend tests pass, build exits 0 (D-11 frontend coverage per CLAUDE.md)
This commit is contained in:
@@ -31,6 +31,19 @@
|
||||
<div v-if="doc.is_shared" class="mt-2">
|
||||
<span class="bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-1 rounded-full">Shared</span>
|
||||
</div>
|
||||
|
||||
<!-- Classification failed badge + Re-analyze button -->
|
||||
<div v-if="doc.status === 'classification_failed'" class="mt-2 flex items-center gap-2">
|
||||
<span class="bg-red-50 text-red-600 text-xs font-medium px-2 py-1 rounded-full">Classification failed</span>
|
||||
<button
|
||||
@click.stop="reanalyze"
|
||||
:disabled="reanalyzing"
|
||||
class="text-xs text-indigo-600 hover:text-indigo-700 font-semibold disabled:opacity-50"
|
||||
>
|
||||
<span v-if="reanalyzing">Re-analyzing…</span>
|
||||
<span v-else>Re-analyze</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons (hover-reveal) -->
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -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: '<span></span>' },
|
||||
}))
|
||||
|
||||
vi.mock('../src/components/sharing/ShareModal.vue', () => ({
|
||||
default: { template: '<div></div>' },
|
||||
}))
|
||||
|
||||
// ── 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'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user