arm-core/web/src/utils/consolidations.test.ts
Andrey Epifancev 68a0af3450 Merge remote-tracking branch 'origin/main' into feat/197-back-to-primary-docs
# Conflicts:
#	web/src/utils/consolidations.test.ts
#	web/src/utils/consolidations.ts
2026-10-27 23:11:20 +04:00

289 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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');
});
});