222 lines
20 KiB
TypeScript
222 lines
20 KiB
TypeScript
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'
|
|
import CollectionProgressGauge from './CollectionProgressGauge.vue'
|
|
import type { Task, TaskDetail, StageEstimate, WarehouseOverview } from '../types'
|
|
const task: Task = { taskNo: 'TASK-W', warehouseCode: 'W', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'WAITING_SOURCE', dependencyState: 'WAITING_CONFIRMED', progressSourceTaskNo: 'TASK-S', queryFrom: null, queryUntil: null, applyState: null, attemptCount: 0, latestAttemptNo: null, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: [], actionBlockReason: null, progress: null }
|
|
const progress = { phase: 'DETAIL', progressScope: 'ACCOUNT_SHARED', countBasis: 'TASK_CUMULATIVE', attemptNo: 2, scanRound: 1, completed: 339, total: 2787, totalKnown: true, unit: 'ORDER', sliceFrom: null, sliceUntil: null, progressUpdatedAt: '2026-10-13T01:01:00Z' }
|
|
const source: Task = { ...task, taskNo: 'TASK-S', taskStatus: 'RUNNING', dependencyState: 'NONE', progressSourceTaskNo: null, attemptCount: 2, progress }
|
|
const detail = { task, sharedUpstream: source, observedAt: '2026-10-13T01:02:00Z', protection: {} } as TaskDetail
|
|
const wrappers: ReturnType<typeof mount>[] = []
|
|
afterEach(() => { wrappers.splice(0).forEach(w => w.unmount()); vi.useRealTimers() })
|
|
describe('collection task progress evidence', () => {
|
|
it('animates unknown totals only during active visible execution and retains the chart across polls', async () => {
|
|
const unknown = { ...source, progress: { ...progress, totalKnown: false, total: null } }
|
|
const w = mount(CollectionTaskProgress, { props: { task: unknown, visible: true } }); wrappers.push(w)
|
|
const chart = w.get('.ring-chart').element
|
|
expect(w.get('.ring-chart').classes()).toContain('is-indeterminate')
|
|
expect(w.get('.ring-label strong').text()).toBe('执行中')
|
|
await w.setProps({ task: { ...unknown, progress: { ...unknown.progress, completed: 46 } } })
|
|
expect(w.get('.ring-chart').element).toBe(chart)
|
|
for (const state of [{ stale: true }, { stale: false, visible: false }, { visible: true, task: { ...unknown, taskStatus: 'RETRY_WAIT' } }]) {
|
|
await w.setProps(state)
|
|
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
|
|
}
|
|
await w.setProps({ task: source, visible: true, stale: false })
|
|
expect(w.get('.ring-label strong').text()).toBe('12.1%')
|
|
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
|
|
})
|
|
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('keeps a failure summary once across compact, stale, hidden and controlled displays', async () => {
|
|
const failed: Task = { ...source, taskStatus: 'RETRY_WAIT', blockingReason: 'UPSTREAM_FAILED', latestAttemptNo: 2,
|
|
latestAttempt: { attemptNo: 2, status: 'FAILED', phase: 'DETAIL', startedAt: null, finishedAt: null, progressUpdatedAt: null, heartbeatAt: null, executionHealth: null, elapsedSeconds: 20, errorCode: 'UPSTREAM_ERROR', failureSummary: '来源读取失败' } }
|
|
const w = mount(CollectionTaskProgress, { props: { task: failed } }); wrappers.push(w)
|
|
for (const compact of [false, true]) {
|
|
for (const state of [
|
|
{ stale: false, visible: true, collectionMode: 'BUSINESS' },
|
|
{ stale: true, visible: true, collectionMode: 'BUSINESS' },
|
|
{ stale: false, visible: false, collectionMode: 'BUSINESS' },
|
|
{ stale: false, visible: true, collectionMode: 'OFF' },
|
|
]) {
|
|
await w.setProps({ compact, ...state })
|
|
expect(w.text().match(/来源读取失败/g)).toHaveLength(1)
|
|
expect(w.findAll('[role="status"]').filter(node => node.text() === '来源读取失败')).toHaveLength(1)
|
|
if (!state.stale && state.visible && state.collectionMode === 'BUSINESS') expect(w.text()).toContain('关联来源失败,等待恢复')
|
|
}
|
|
}
|
|
})
|
|
it('hides redundant estimate notices under off, pause and stale states while retaining counts', 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, collectionMode: 'OFF', compact: true } }); wrappers.push(w)
|
|
expect(w.find('.collection-estimate').exists()).toBe(false)
|
|
expect(w.text()).not.toContain('等待续采')
|
|
expect(w.text()).toContain('12.1%')
|
|
expect(w.find('.compact-counts [aria-hidden="true"]').exists()).toBe(false)
|
|
await w.setProps({ collectionMode: 'BUSINESS', accountPaused: true })
|
|
expect(w.find('.collection-estimate').exists()).toBe(false)
|
|
await w.setProps({ stale: true })
|
|
expect(w.find('.collection-estimate').exists()).toBe(false)
|
|
await w.setProps({ stale: false, visible: false })
|
|
expect(w.find('.collection-estimate').exists()).toBe(false)
|
|
})
|
|
it('keeps an executing attempt visible under an off switch and pauses only stale animation', async () => {
|
|
const unknown = { ...source, blockingReason: 'COLLECTION_OFF', progress: { ...progress, totalKnown: false, total: null } }
|
|
const w = mount(CollectionTaskProgress, { props: { task: unknown, compact: true } }); wrappers.push(w)
|
|
expect(w.find('.collection-estimate').exists()).toBe(false)
|
|
expect(w.get('.ring-chart').classes()).toContain('is-indeterminate')
|
|
await w.setProps({ stale: true })
|
|
expect(w.find('.collection-estimate').exists()).toBe(false)
|
|
expect(w.get('.ring-chart').classes()).not.toContain('is-indeterminate')
|
|
})
|
|
it('marks only the evidenced current phase as paused and does not treat paused estimates as paused execution', async () => {
|
|
const paused: Task = { ...source, dataFamily: 'MASTER_DATA', taskStatus: 'RETRY_WAIT', blockingReason: 'ACCOUNT_PAUSED', progress: { ...progress, phase: 'PRODUCTS' } }
|
|
const w = mount(CollectionTaskProgress, { props: { task: paused } }); wrappers.push(w)
|
|
expect(w.findAll('.collection-flow li').map(li => li.get('span').text())).toEqual(['状态未确认', '当前阶段 · 已暂停', '状态未确认'])
|
|
expect(w.text()).not.toContain('正在处理')
|
|
await w.setProps({ task: { ...paused, blockingReason: 'EXECUTION_DISABLED' } })
|
|
expect(w.get('.density-actions span').text()).toBe('执行未启用')
|
|
await w.setProps({ task: { ...paused, taskStatus: 'RUNNING', blockingReason: null, stageEstimate: { status: 'PAUSED' } as StageEstimate } })
|
|
expect(w.get('.density-actions span').text()).toBe('正在执行')
|
|
expect(w.findAll('.collection-flow li')[1]?.text()).toContain('正在处理')
|
|
expect(w.get('.collection-estimate strong').text()).toBe('已暂停估算')
|
|
})
|
|
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')
|
|
expect(w.classes()).toContain('admin-workbench')
|
|
expect(w.get('.collection-stage-counts').classes()).toContain('admin-workbench')
|
|
expect(w.findAll('.collection-count-grid .workbench-metric.is-compact')).toHaveLength(3)
|
|
expect(w.findAll('.collection-count-grid dd strong').map(value => value.text())).toEqual(['2787', '339', '2448'])
|
|
expect(w.get('.collection-estimate').classes()).toContain('workbench-notice')
|
|
expect(w.text()).toContain('账号共享'); expect(w.findAll('li')[0]?.text()).toContain('状态未确认')
|
|
expect(w.text()).not.toContain('✓')
|
|
await w.setProps({ task: { ...task, dependencyState: 'WAITING_UNCONFIRMED', progressSourceTaskNo: null } })
|
|
expect(w.text()).toContain('关联进度尚未确认'); expect(w.text()).not.toContain('339'); expect(w.text()).not.toContain('12.1%')
|
|
})
|
|
it('accepts only explicit matching stage completion and keeps publication independent', () => {
|
|
const w = mount(CollectionTaskProgress, { props: { task, detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, stages: [{ ...progress, phase: 'DIRECTORY', stageFinishedAt: '2026-10-13T01:00:00Z' }, { ...progress, phase: 'PUBLISH', stageFinishedAt: '2026-10-13T01:00:00Z' }] } } } } }); wrappers.push(w)
|
|
expect(w.findAll('li')[0]?.text()).toContain('✓ 已结束')
|
|
expect(w.findAll('li')[4]?.text()).not.toContain('✓')
|
|
expect(w.text()).toContain('报表可用状态待确认')
|
|
})
|
|
it('retains confirmed source evidence after dependency failure without borrowing its current count', () => {
|
|
const w = mount(CollectionTaskProgress, { props: { task: { ...task, dependencyState: 'FAILED', progressSourceTaskNo: task.taskNo, blockingReason: 'UPSTREAM_FAILED' }, detail } }); wrappers.push(w)
|
|
expect(w.text()).toContain('已确认关联来源:TASK-S')
|
|
expect(w.text()).toContain('关联来源失败,等待恢复')
|
|
expect(w.text()).not.toContain('12.1%')
|
|
})
|
|
it('treats COUNT_COMPLETE including zero of zero only as completed counts', () => {
|
|
const stageEstimate: StageEstimate = { status: 'UNAVAILABLE', progressIdentity: 'zero-count', completed: '0', total: '0', sampleFrom: null, sampleUntil: null, completedDelta: null, ratePerSecond: null, remainingSeconds: null, estimatedAt: null, validUntil: null, unavailableReason: 'COUNT_COMPLETE' }
|
|
const w = mount(CollectionTaskProgress, { props: { task, visible: true, detail: { ...detail, sharedUpstream: { ...source, progress: { ...progress, completed: 0, total: 0 }, stageEstimate } } } }); wrappers.push(w)
|
|
expect(w.text()).toContain('计数已齐,等待阶段确认')
|
|
expect(w.text()).not.toContain('✓ 已结束')
|
|
expect(w.text()).not.toContain('预计还需')
|
|
expect(w.find('[role="progressbar"]').exists()).toBe(false)
|
|
})
|
|
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', '本次执行采样中,需积累至少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('本次执行采样中')
|
|
})
|
|
it('distinguishes unobserved warehouse metadata from a confirmed missing publication', async () => {
|
|
const w = mount(CollectionTaskProgress, { props: { task, detail } }); wrappers.push(w)
|
|
expect(w.text()).toContain('报表可用状态待确认')
|
|
expect(w.text()).not.toContain('尚无可用数据')
|
|
await w.setProps({ warehouse: { lastPublishedAt: null } as WarehouseOverview })
|
|
expect(w.text()).toContain('尚无可用数据')
|
|
expect(w.text()).not.toContain('报表可用状态待确认')
|
|
await w.setProps({ warehouse: { lastPublishedAt: '2026-10-13T01:00:00Z' } as WarehouseOverview })
|
|
expect(w.text()).toContain('已发布数据')
|
|
expect(w.text()).not.toContain('尚无可用数据')
|
|
})
|
|
it('withdraws ETA when expired, count identity changes, hidden, or reads fail', async () => {
|
|
vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-13T01:02:00Z'))
|
|
const stageEstimate: StageEstimate = { status: 'AVAILABLE', progressIdentity: 'source-attempt-2-detail-1', completed: 339, total: 2787, sampleFrom: '2026-10-13T01:00:00Z', sampleUntil: '2026-10-13T01:02:00Z', completedDelta: 60, ratePerSecond: 0.5, remainingSeconds: 4896, estimatedAt: '2026-10-13T01:02:00Z', validUntil: '2026-10-13T01:02:10Z', unavailableReason: null }
|
|
const d = { ...detail, sharedUpstream: { ...source, stageEstimate } }
|
|
const w = mount(CollectionTaskProgress, { props: { task, detail: d, visible: true } }); wrappers.push(w)
|
|
expect(w.text()).toContain('约82分钟')
|
|
await w.setProps({ compact: true })
|
|
expect(w.get('.collection-estimate').text()).toBe('当前阶段预计还需约82分钟')
|
|
expect(w.get('.collection-estimate strong').attributes('title')).toContain('最近速度 0.5 单/秒')
|
|
expect(w.get('.collection-estimate strong').attributes('title')).toContain('估算更新')
|
|
expect(w.get('.collection-estimate strong').attributes('title')).toContain('整个任务完成时间尚未估算')
|
|
await w.setProps({ compact: false })
|
|
expect(w.get('.collection-estimate').text()).toContain('最近速度')
|
|
expect(w.get('.collection-estimate').text()).toContain('整个任务完成时间尚未估算')
|
|
await w.setProps({ stale: true }); expect(w.text()).not.toContain('约82分钟'); expect(w.text()).toContain('状态更新失败')
|
|
await w.setProps({ stale: false, visible: false }); expect(w.text()).not.toContain('约82分钟')
|
|
await w.setProps({ visible: true, detail: { ...d, sharedUpstream: { ...d.sharedUpstream, progress: { ...progress, completed: 340 } } } }); expect(w.text()).not.toContain('约82分钟')
|
|
await w.setProps({ detail: d }); await vi.advanceTimersByTimeAsync(10001); expect(w.text()).toContain('估算已过期'); expect(w.text()).not.toContain('约82分钟')
|
|
})
|
|
it('never paints a percentage for unknown, zero, invalid, or changing denominators', async () => {
|
|
const w = mount(CollectionProgressGauge, { props: { taskNo: source.taskNo, progress } }); wrappers.push(w)
|
|
expect(w.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('12.1')
|
|
await w.setProps({ progress: { ...progress, total: 3000 } }); expect(w.find('[role="progressbar"]').exists()).toBe(false); expect(w.text()).toContain('正在复核')
|
|
await w.setProps({ progress: { ...progress, attemptNo: 3, totalKnown: false } }); expect(w.find('[role="progressbar"]').exists()).toBe(false)
|
|
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)
|
|
await w.setProps({ progress: { ...progress, attemptNo: 6, total: null, completed: 0 } })
|
|
expect(w.find('[role="progressbar"]').exists()).toBe(false)
|
|
expect(w.find('.collection-count-grid dd').text()).toContain('—')
|
|
expect(w.text()).toContain('总量或计数依据待确认')
|
|
expect(w.text()).not.toContain('本阶段数量为零')
|
|
await w.setProps({ progress: { ...progress, attemptNo: 7, total: 0, completed: 0 } })
|
|
expect(w.find('[role="progressbar"]').exists()).toBe(false)
|
|
expect(w.find('.collection-count-grid dd strong').text()).toBe('0')
|
|
expect(w.text()).toContain('本阶段数量为零')
|
|
})
|
|
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')
|
|
})
|
|
})
|