import { describe, expect, it, vi } from 'vitest'; import type { CounterPairOut } from '../types/api'; import type { ConsStatus, LockOut } from '../types/consolidations/common_types'; import { CONS_STATUS_LABEL, FINISHED_STATUSES, MANUAL_TRANSITIONS, canChangeStatus, canSubmitStatusChange, canTakeInWork, consStatusDescription, counterPairsKey, counterPairsWarning, decideOsvLeave, endSentence, isLockedBy, isValidReason, manualTransitions, panelEntity, parseInWorkOnly, rememberCounterPairsShown, reportTabNavigation, resolveLeaveAfterFetch, shouldWarnCounterPairs, versionLabel, withPanelEntity, } from './consolidations'; const lock = (userId: string): LockOut => ({ locked_by: userId, locked_by_name: 'Соколов А. В.', locked_at: '2026-10-23T10:00:00Z', taken_over_from: null, takeover_reason: null, }); const ALL: ConsStatus[] = Object.keys(CONS_STATUS_LABEL) as ConsStatus[]; describe('versionLabel', () => { it('первая версия — пусто, последующие — номер', () => { expect(versionLabel(1)).toBe(''); expect(versionLabel(2)).toBe('2'); expect(versionLabel(10)).toBe('10'); }); }); describe('parseInWorkOnly', () => { it('по умолчанию выключен, включает только явное true', () => { expect(parseInWorkOnly(null)).toBe(false); expect(parseInWorkOnly('')).toBe(false); expect(parseInWorkOnly('false')).toBe(false); expect(parseInWorkOnly('true')).toBe(true); }); }); describe('подписи и пояснения статусов', () => { it('подписи из спеки', () => { expect(CONS_STATUS_LABEL.DATA_CORRECTION).toBe('Корректировка данных'); expect(CONS_STATUS_LABEL.CANCELLED).toBe('Отменена'); }); it('пояснение не повторяет подпись', () => { for (const s of ALL) { expect(consStatusDescription(s)).not.toBe(CONS_STATUS_LABEL[s]); } }); it('пояснения статусов первой очереди и макета заданы', () => { for (const s of ['INITIATED', 'DOCUMENTS_PROCESSING', 'DATA_CORRECTION', 'CANCELLED', 'CONSOLIDATION_RF', 'VERIFIED'] as const) { expect(consStatusDescription(s)).not.toBe(''); } expect(consStatusDescription('CONSOLIDATION_RF')).toBe('Автоматическая консолидация, ручные проверки и сравнение КФО клиента и банка'); expect(consStatusDescription('VERIFIED')).toBe('Отчет верифицирован ГО, КА ГО внес в систему номер и дату решения УОБ'); }); }); describe('ручные переходы (зеркало MANUAL_TRANSITIONS бэка)', () => { it('совпадают с api/src/services/consolidation_status.py', () => { expect(MANUAL_TRANSITIONS).toEqual({ INITIATED: ['CANCELLED'], DOCUMENTS_PROCESSING: ['CANCELLED', 'INITIATED'], DATA_CORRECTION: ['CANCELLED', 'INITIATED'], }); }); it('из остальных статусов переходов нет', () => { for (const s of ['VERIFIED', 'CANCELLED', 'CONSOLIDATION_RF', 'VERIFICATION_RF', 'CONSOLIDATION_GO', 'VERIFICATION_GO'] as const) { expect(manualTransitions(s)).toEqual([]); } }); }); describe('блокировка и «Изменить статус»', () => { it('своя блокировка', () => { expect(isLockedBy(lock('me'), 'me')).toBe(true); expect(isLockedBy(lock('other'), 'me')).toBe(false); expect(isLockedBy(null, 'me')).toBe(false); expect(isLockedBy(undefined, 'me')).toBe(false); }); it('кнопка: своя / чужая / нет блокировки × есть / нет переходов', () => { expect(canChangeStatus('DATA_CORRECTION', lock('me'), 'me')).toBe(true); expect(canChangeStatus('DATA_CORRECTION', lock('other'), 'me')).toBe(false); expect(canChangeStatus('DATA_CORRECTION', null, 'me')).toBe(false); expect(canChangeStatus('VERIFIED', lock('me'), 'me')).toBe(false); expect(canChangeStatus('VERIFIED', lock('other'), 'me')).toBe(false); expect(canChangeStatus('VERIFIED', null, 'me')).toBe(false); }); it('«Взять в работу» недоступна только у отменённой версии', () => { expect(canTakeInWork('CANCELLED')).toBe(false); expect(canTakeInWork('INITIATED')).toBe(true); expect(canTakeInWork('VERIFIED')).toBe(true); }); }); describe('причина и комментарий', () => { it('пустая и из пробелов — неверна', () => { expect(isValidReason('')).toBe(false); expect(isValidReason(' ')).toBe(false); expect(isValidReason(undefined)).toBe(false); expect(isValidReason(null)).toBe(false); }); it('ровно 1000 символов можно, 1001 — нет', () => { expect(isValidReason('a'.repeat(1000))).toBe(true); expect(isValidReason('a'.repeat(1001))).toBe(false); expect(isValidReason(` ${'a'.repeat(1000)} `)).toBe(true); }); it('модалка: нужен выбранный этап и непустой комментарий', () => { expect(canSubmitStatusChange(undefined, 'Дубль')).toBe(false); expect(canSubmitStatusChange('CANCELLED', undefined)).toBe(false); expect(canSubmitStatusChange('CANCELLED', ' ')).toBe(false); expect(canSubmitStatusChange('CANCELLED', 'Дубль')).toBe(true); }); }); describe('endSentence', () => { it('не ставит вторую точку после инициалов', () => { expect(endSentence('Соколов А. В.')).toBe('Соколов А. В.'); expect(endSentence('Dev: Аналитик ГО')).toBe('Dev: Аналитик ГО.'); }); }); describe('вкладки отчёта (AURORA-1851)', () => { it('смена вкладки заменяет запись истории: «Назад» ведёт со страницы отчёта, а не по вкладкам', () => { expect(reportTabNavigation('/groups/g1/versions/v1/osv', 'osv', 'chess')).toEqual({ to: '/groups/g1/versions/v1/chess', options: { replace: true }, }); }); }); describe('панель «Первичные документы» в адресе (AURORA-1682)', () => { it('открытие добавляет ?entity= к выбранной версии, исходные параметры не меняются', () => { const params = new URLSearchParams('version=v1&in_work_only=true'); const next = withPanelEntity(params, 'le1'); expect(next.toString()).toBe('version=v1&in_work_only=true&entity=le1'); expect(params.toString()).toBe('version=v1&in_work_only=true'); }); it('закрытие убирает ?entity=, версия остаётся', () => { expect(withPanelEntity(new URLSearchParams('version=v1&entity=le1'), null).toString()).toBe('version=v1'); }); it('организация панели читается из адреса', () => { expect(panelEntity(new URLSearchParams('version=v1&entity=le1'))).toBe('le1'); expect(panelEntity(new URLSearchParams('version=v1'))).toBeNull(); }); }); const pair = (holder: string, missing: string): CounterPairOut => ({ holder_id: holder, holder_name: `H${holder}`, missing_id: missing, missing_name: `M${missing}`, holder_rows: 1, missing_unmapped_rows: 0, missing_rows_total: 0, }); describe('counterPairsKey', () => { it('не зависит от порядка', () => { expect(counterPairsKey([pair('a', 'b'), pair('c', 'd')])).toBe(counterPairsKey([pair('c', 'd'), pair('a', 'b')])); expect(counterPairsKey([pair('c', 'd'), pair('a', 'b')])).toBe('a>b,c>d'); }); }); describe('shouldWarnCounterPairs', () => { const pairs = [pair('a', 'b')]; const withKey = (v: string | null) => ({ getItem: () => v }); it('пусто или завершённый статус — false', () => { expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', [], withKey(null))).toBe(false); for (const s of FINISHED_STATUSES) expect(shouldWarnCounterPairs('v', s, pairs, withKey(null))).toBe(false); expect(shouldWarnCounterPairs('v', 'CANCELLED', pairs, withKey(null))).toBe(false); }); it('ключ в хранилище', () => { expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey(null))).toBe(true); expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey('a>b'))).toBe(false); expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey('x>y'))).toBe(true); }); it('null и throwing storage — true', () => { expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, null)).toBe(true); const throwing = { getItem: () => { throw new Error('denied'); }, }; expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, throwing)).toBe(true); }); }); describe('rememberCounterPairsShown', () => { it('пишет ключ', () => { const setItem = vi.fn(); rememberCounterPairsShown('v1', [pair('a', 'b')], { setItem }); expect(setItem).toHaveBeenCalledWith('armka:counter-pairs:v1', 'a>b'); }); it('глотает исключения и null', () => { const throwing = { setItem: () => { throw new Error('denied'); }, }; expect(() => rememberCounterPairsShown('v1', [pair('a', 'b')], throwing)).not.toThrow(); expect(() => rememberCounterPairsShown('v1', [pair('a', 'b')], null)).not.toThrow(); }); }); describe('counterPairsWarning', () => { it('одна пара у одного участника', () => { const w = counterPairsWarning([pair('a', 'b')], 1260); expect(w.headline).toBe('Нет встречных записей: 1 пара у 1 участника.'); expect(w.details.endsWith('Ещё 1260 строк без ИНН учтены как внешние.')).toBe(true); }); it('пять пар у трёх участников (M — разные missing_id)', () => { const ps = [pair('a', 'c'), pair('a', 'd'), pair('a', 'e'), pair('b', 'c'), pair('b', 'd')]; expect(counterPairsWarning(ps, 5).headline).toBe('Нет встречных записей: 5 пар у 3 участников.'); }); it('M считает missing_id, а не holder_id', () => { const ps = [pair('a', 'b'), pair('a', 'c'), pair('d', 'b'), pair('e', 'b'), pair('f', 'b')]; expect(counterPairsWarning(ps, 5).headline).toBe('Нет встречных записей: 5 пар у 2 участников.'); }); it('две пары у двух участников', () => { expect(counterPairsWarning([pair('a', 'b'), pair('a', 'c')], 1).headline).toBe('Нет встречных записей: 2 пары у 2 участников.'); }); it('без строк без ИНН', () => { expect(counterPairsWarning([pair('a', 'b')], 0).details).toBe( 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.', ); }); }); describe('decideOsvLeave (T4.2)', () => { const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut]; const noStorage = { getItem: () => null }; const base = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: noStorage }; it('сводки нет и версия открыта — запросить', () => { expect(decideOsvLeave({ ...base, summary: null })).toBe('perform'); }); it('версия закрыта — переход без запроса', () => { expect(decideOsvLeave({ ...base, status: 'VERIFIED', summary: null })).toBe('go'); }); it('сводка есть, пары есть и не показывали — предупредить', () => { expect(decideOsvLeave({ ...base, summary: { counter_pairs: pairs } })).toBe('warn'); }); it('сводка есть, пар нет — перейти', () => { expect(decideOsvLeave({ ...base, summary: { counter_pairs: [] } })).toBe('go'); }); it('список уже показывали — перейти', () => { const shown = { getItem: () => 'a>b' }; expect(decideOsvLeave({ ...base, storage: shown, summary: { counter_pairs: pairs } })).toBe('go'); }); }); describe('resolveLeaveAfterFetch (T7.3)', () => { const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut]; const args = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: { getItem: () => null } }; it('успех с парами — warn', () => { expect(resolveLeaveAfterFetch({ ...args, summary: { counter_pairs: pairs } })).toBe('warn'); }); it('успех без пар — go', () => { expect(resolveLeaveAfterFetch({ ...args, summary: { counter_pairs: [] } })).toBe('go'); }); it('ошибка запроса (null) — go', () => { expect(resolveLeaveAfterFetch({ ...args, summary: null })).toBe('go'); }); }); describe('reportTabNavigation: адрес без search (T7.4)', () => { it('?holder=&missing= не переносятся на другую вкладку', () => { expect(reportTabNavigation('/groups/g/versions/v/osv', 'osv', 'chess').to).toBe('/groups/g/versions/v/chess'); }); });