Files
admin-web/src/modules/self-operated/state.test.ts
T

93 lines
6.0 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { RequestFence, RetryIntent, RuntimePoller, contextParams, label, reasonsText, progressText, sameContext, shownQuantity, taskRetryAllowed, validateCustomRange } from './state'
import type { QueryContext, Task } from './types'
const context: QueryContext = { salesRunId: '9007199254740999', inventoryRunId: null, salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: '2026-10-12T15:00:00+08:00', queryExpiresAt: '2026-10-12T15:30:00+08:00', businessTimezone: 'Asia/Shanghai', anchorDate: '2026-10-12' }
const task: Task = { taskNo: 'COL-TEST-001', warehouseCode: 'WH-A', taskType: 'BACKFILL', dataFamily: 'OUTBOUND', taskStatus: 'FAILED', applyState: 'BLOCKED', attemptCount: 1, latestAttemptNo: 1, resultPublicationState: 'NOT_PUBLISHED', nextRetryAt: null, allowedActions: ['RETRY'], actionBlockReason: null, progress: null, queryFrom: null, queryUntil: null }
describe('self-operated query and command protection', () => {
it('explains pending publication and unfinished days instead of a generic unknown reason', () => {
expect(label('REBUILD_REQUIRED')).toBe('销量待重新统计发布')
expect(reasonsText(['SALES_REBUILD_REQUIRED', 'TODAY_INCOMPLETE', 'SALES_SOURCE_TIMEZONE_UNKNOWN'])).toBe('销量有变动,待重新统计发布、今日尚未结束,不能作为完整自然日、销量来源时区未确认')
})
it('aborts old warehouse request and rejects late completion even if adapter ignores abort', () => {
const fence = new RequestFence()
const oldWarehouse = fence.begin()
const newWarehouse = fence.begin()
expect(oldWarehouse.signal.aborted).toBe(true)
expect(oldWarehouse.current()).toBe(false)
expect(newWarehouse.current()).toBe(true)
fence.cancel()
expect(newWarehouse.current()).toBe(false)
})
it('preserves unavailable family and long string IDs in version context across ranges', () => {
expect(contextParams(context)).toEqual({ salesRunId: '9007199254740999', inventoryRunId: '0', salesScopeHash: 'sales', inventoryScopeHash: null, queryAsOf: context.queryAsOf })
expect(sameContext(context, { ...context, rangeStartDate: '2026-10-01' })).toBe(true)
expect(sameContext(context, { ...context, inventoryRunId: 'new' })).toBe(false)
expect(sameContext(context, { ...context, queryAsOf: '2026-10-13T00:01:00+08:00' })).toBe(false)
})
it('validates real dates, inclusive 366-day maximum and frozen anchor date', () => {
expect(validateCustomRange('2026-02-30', '2026-03-01')).toBeTruthy()
expect(validateCustomRange('2025-10-12', '2026-10-12', '2026-10-12')).toBe('')
expect(validateCustomRange('2025-10-11', '2026-10-12', '2026-10-12')).toContain('366')
expect(validateCustomRange('2026-10-12', '2026-10-13', '2026-10-12')).toContain('基准日')
})
it('shows partial observed quantity and keeps unknown null distinct from reliable zero', () => {
expect(shownQuantity({ outboundQuantity: null, observedQuantity: '2.75', dataStatus: 'PARTIAL' })).toBe('2.75')
expect(shownQuantity({ outboundQuantity: null, observedQuantity: null, dataStatus: 'UNKNOWN' })).toBeNull()
expect(shownQuantity({ outboundQuantity: '0', observedQuantity: '0', dataStatus: 'COMPLETE' })).toBe('0')
})
it('requires permission, server eligibility, correct warehouse and reason for retry', () => {
const scope = { sourceAccount: 'lingxing-primary', warehouseCode: 'WH-A' }
expect(taskRetryAllowed(task, true, scope, '修复后恢复')).toBe(true)
expect(taskRetryAllowed(task, false, scope, '修复后恢复')).toBe(false)
expect(taskRetryAllowed({ ...task, allowedActions: [] }, true, scope, '修复后恢复')).toBe(false)
expect(taskRetryAllowed({ ...task, actionBlockReason: '账号冷却' }, true, scope, '修复后恢复')).toBe(false)
expect(taskRetryAllowed(task, true, { ...scope, warehouseCode: 'WH-B' }, '修复后恢复')).toBe(false)
expect(taskRetryAllowed(task, true, scope, ' ')).toBe(false)
})
it('reuses exact retry request after ambiguous failure, changes key only for changed intent or accepted request', () => {
const intent = new RetryIntent()
let counter = 0
const key = () => `key-${++counter}`
const first = intent.create(task.taskNo, 'WH-A', ' 修复 ', key)
expect(intent.create(task.taskNo, 'WH-A', '修复', key)).toBe(first)
expect(intent.create(task.taskNo, 'WH-A', '新的原因', key).requestKey).toBe('key-2')
intent.accepted()
expect(intent.create(task.taskNo, 'WH-A', '新的原因', key).requestKey).toBe('key-3')
expect(first).not.toHaveProperty('taskId')
})
it('does not invent progress denominator or combine warehouse and account counts', () => {
const text = progressText({ progressScope: 'ACCOUNT_SHARED', countBasis: 'ATTEMPT_ONLY', attemptNo: 2, scanRound: 2, phase: 'DIRECTORY', completed: 3, total: null, totalKnown: false, unit: 'PAGE', sliceFrom: null, sliceUntil: null })
expect(text).toContain('账号共享')
expect(text).toContain('本次尝试')
expect(text).toContain('总量待确认')
expect(text).not.toContain('%')
})
})
describe('self-operated polling lifecycle', () => {
afterEach(() => vi.useRealTimers())
it('uses 10s active and 60s idle, stops while hidden and after leave', async () => {
vi.useFakeTimers()
const read = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false)
const cancel = vi.fn()
const poller = new RuntimePoller(read, cancel)
poller.start()
await vi.advanceTimersByTimeAsync(9999)
expect(read).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(1)
expect(read).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(59999)
expect(read).toHaveBeenCalledTimes(2)
poller.setVisible(false)
await vi.advanceTimersByTimeAsync(120000)
expect(read).toHaveBeenCalledTimes(2)
poller.setVisible(true)
await vi.advanceTimersByTimeAsync(0)
expect(read).toHaveBeenCalledTimes(3)
poller.stop()
await vi.advanceTimersByTimeAsync(120000)
expect(read).toHaveBeenCalledTimes(3)
expect(cancel).toHaveBeenCalled()
})
})