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() }) })