feat(self-operated): add charts and continuous collection status

This commit is contained in:
2026-10-13 20:44:31 +08:00
parent 698ca34529
commit 4c4a3db1eb
33 changed files with 780 additions and 211 deletions
@@ -1,3 +1,4 @@
vi.mock('vue-echarts', () => ({ default: { name: 'VChart', props: ['option'], template: '<div />' } }))
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import CollectionTaskProgress from './CollectionTaskProgress.vue'
@@ -10,6 +11,46 @@ const detail = { task, sharedUpstream: source, observedAt: '2026-10-13T01:02:00Z
const wrappers: ReturnType<typeof mount>[] = []
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
describe('collection task progress evidence', () => {
it('explains a confirmed normal batch continuation without hiding failures or protection blocks', async () => {
const yielded: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED', latestAttemptNo: 2,
latestAttempt: { attemptNo: 2, status: 'YIELDED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: null, failureSummary: null } }
const w = mount(CollectionTaskProgress, { props: { task: yielded, compact: true, visible: true } }); wrappers.push(w)
expect(w.find('.collection-estimate').text()).toContain('本批已完成,等待续采')
await w.setProps({ task: { ...yielded, latestAttempt: { ...yielded.latestAttempt!, status: 'FAILED' } } })
expect(w.find('.collection-estimate').text()).toContain('等待计划重试')
await w.setProps({ task: { ...yielded, blockingReason: 'ACCOUNT_COOLDOWN' } })
expect(w.find('.collection-estimate').text()).not.toContain('本批已完成,等待续采')
})
it('reads serialized Long seconds and rejects missing, malformed or unsafe values', async () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...source, elapsedSeconds: '607' }, compact: true } }); wrappers.push(w)
expect(w.text()).toContain('任务累计耗时 10 分 7 秒')
await w.setProps({ task: { ...source, elapsedSeconds: '0' } })
expect(w.text()).toContain('任务累计耗时 0 分 0 秒')
for (const elapsedSeconds of [null, undefined, '', ' ', '-1', '1.5', 'NaN', '9007199254740992', -1]) {
await w.setProps({ task: { ...source, elapsedSeconds } })
expect(w.text()).toContain('任务累计耗时 —')
}
})
it('shows task lifetime across retries and stage changes without borrowing upstream elapsed time', async () => {
const current = { ...task, elapsedSeconds: 607 }
const w = mount(CollectionTaskProgress, { props: { task: current, detail: { ...detail, sharedUpstream: { ...source, elapsedSeconds: 7 } } } }); wrappers.push(w)
expect(w.text()).toContain('任务累计耗时 10 分 7 秒')
expect(w.text()).toContain('含排队与重试')
await w.setProps({ task: { ...current, taskStatus: 'RUNNING', dependencyState: 'SATISFIED', attemptCount: 3, elapsedSeconds: 667, progress: { ...progress, phase: 'MERGE', stageStartedAt: '2026-10-13T01:01:59Z' } } })
expect(w.text()).toContain('任务累计耗时 11 分 7 秒')
await w.setProps({ task: { ...current, elapsedSeconds: null } })
expect(w.text()).toContain('任务累计耗时 —')
})
it('keeps waiting reasons below the gauge and explains unknown directory totals', async () => {
const w = mount(CollectionTaskProgress, { props: { task: { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'RETRY_SCHEDULED' }, compact: true, visible: true } }); wrappers.push(w)
expect(w.element.firstElementChild?.classList.contains('collection-stage-counts')).toBe(true)
expect(w.find('.collection-estimate').text()).toContain('等待计划重试')
expect(w.text()).toContain('12.1%')
await w.setProps({ task: { ...source, progress: { ...progress, phase: 'DIRECTORY', unit: 'PAGE', totalKnown: false, total: null, completed: 0 } } })
expect(w.find('.collection-count-grid dd').text()).toContain('—')
expect(w.text()).toContain('目录总页数尚未返回')
expect(w.find('[role="progressbar"]').exists()).toBe(false)
})
it('uses only a confirmed source and does not infer earlier stage completion from the current phase', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
expect(w.text()).toContain('TASK-S'); expect(w.text()).toContain('12.1%'); expect(w.text()).toContain('2448')
@@ -41,13 +82,13 @@ describe('collection task progress evidence', () => {
it('uses unavailable reasons as the primary estimate text and prioritizes explicit stage evidence', async () => {
const e: StageEstimate = { status: 'UNAVAILABLE', progressIdentity: 'identity', completed: 339, total: 2787, sampleFrom: null, sampleUntil: null, completedDelta: null, ratePerSecond: null, remainingSeconds: null, estimatedAt: null, validUntil: null, unavailableReason: 'TERMINAL' }
const w = mount(CollectionTaskProgress, { props: { task, visible: true, detail: { ...detail, sharedUpstream: { ...source, stageEstimate: e } } } }); wrappers.push(w)
for (const [reason, expected] of [['TERMINAL', '任务已结束,不再估算'], ['COUNT_COMPLETE', '计数已齐,等待阶段确认'], ['NO_RECENT_PROGRESS', '近期无新增进度,暂不估算'], ['SAMPLING', '正在测速,可靠样本尚未齐全'], ['UNRECOGNIZED', '估算依据待确认']]) {
for (const [reason, expected] of [['TERMINAL', '任务已结束,不再估算'], ['COUNT_COMPLETE', '计数已齐,等待阶段确认'], ['NO_RECENT_PROGRESS', '近期无新增进度,暂不估算'], ['SAMPLING', '本次执行采样中,需积累至少1分钟的有效进度'], ['UNRECOGNIZED', '估算依据待确认']]) {
await w.setProps({ detail: { ...detail, sharedUpstream: { ...source, stageEstimate: { ...e, unavailableReason: reason } } } })
expect(w.find('.collection-estimate strong').text()).toBe(expected)
}
await w.setProps({ detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, stageFinishedAt: '2026-10-13T01:02:00Z' }, stageEstimate: { ...e, unavailableReason: 'SAMPLING' } } } })
expect(w.find('.collection-estimate strong').text()).toContain('本阶段已结束')
expect(w.text()).not.toContain('正在测速')
expect(w.text()).not.toContain('本次执行采样中')
})
it('distinguishes unobserved warehouse metadata from a confirmed missing publication', async () => {
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
@@ -79,4 +120,10 @@ describe('collection task progress evidence', () => {
await w.setProps({ progress: { ...progress, attemptNo: 4, total: 0, completed: 0 } }); expect(w.text()).toContain('本阶段数量为零'); expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress: { ...progress, attemptNo: 5, total: 1, completed: 2 } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
})
it('shows the percentage once an initially unknown total becomes available', async () => {
const w = mount(CollectionProgressGauge, { props: { taskNo: source.taskNo, progress: { ...progress, totalKnown: false, total: null } } }); wrappers.push(w)
expect(w.find('[role="progressbar"]').exists()).toBe(false)
await w.setProps({ progress })
expect(w.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('12.1')
})
})