import { describe, expect, it } from 'vitest'; import type { StatementOut, ValueOut } from '../../types/api'; import statementV2Json from './__fixtures__/statement-v2.json'; import type { GridRow } from './statementGrid'; import { applyDraftInput, buildStatementGrid, cellFlags, cellKey, draftToPatch, emptySublineKeys } from './statementGrid'; const v = (id: string | null, column_id: string, column_index: number, extra: Partial = {}): ValueOut => ({ id, column_id, column_index, amount: '1.00', string_value: null, text: null, confidence: '100', is_recognized: true, is_modified: false, original_amount: null, needs_review: false, is_confirmed: false, ...extra, }); // Фикстура v5.19 (тип StatementOut): приведение нужно, т.к. импорт JSON расширяет строковые литералы до string const statementV2 = statementV2Json as StatementOut; const statement = { columns: [ { id: 'c2', column_index: 1, label: '31.12.2023', period_date: null, is_current: false }, { id: 'c1', column_index: 0, label: '30.09.2024', period_date: null, is_current: true }, ], lines: [{ id: 'l1', origin: 'source', line_code: '1600', line_name: 'Баланс', values: [v('v2', 'c2', 1), v('v1', 'c1', 0)] }], } as unknown as StatementOut; describe('buildStatementGrid', () => { it('колонки по column_index, ячейки по column_id', () => { const grid = buildStatementGrid(statement); expect(grid.columns.map((c) => c.id)).toEqual(['c1', 'c2']); expect(grid.rows[0]).toMatchObject({ key: 'l1', lineId: 'l1', code: '1600', name: 'Баланс', origin: 'source' }); expect(grid.rows[0].cells.c1?.id).toBe('v1'); expect(grid.rows[0].cells.c2?.id).toBe('v2'); }); it('буквально переносит поля v2 из фикстуры', () => { const { rows } = buildStatementGrid(statementV2); const byKey = new Map(rows.map((row) => [row.key, row])); expect(byKey.get('l-u7')).toMatchObject({ lineStatus: 'unreadable', rowIndex: 7, codeText: 'ИЧ,250', code: null, key: 'l-u7', }); expect(byKey.get('l-d2')?.parentRowIndex).toBe(1); expect(byKey.get('code-1250')).toMatchObject({ key: 'code-1250', rowIndex: null, referenceName: 'Денежные средства и денежные эквиваленты', }); expect(byKey.get('l-1230')).toMatchObject({ needsReview: true, codeConfidence: '50' }); }); it('каждая пара «строка × колонка» фикстуры имеет ValueOut', () => { const { columns, rows } = buildStatementGrid(statementV2); for (const row of rows) { for (const column of columns) { expect(row.cells[column.id], `${row.key} × ${column.id}`).toBeDefined(); } } }); it('достроенная строка: ключ по коду, lineId = null', () => { const withTemplate = { ...statement, lines: [ ...statement.lines, { id: null, origin: 'template', line_code: '1700', line_name: 'БАЛАНС', values: [v(null, 'c1', 0, { amount: null, confidence: null, is_recognized: null })], }, ], } as unknown as StatementOut; const row = buildStatementGrid(withTemplate).rows[1]; expect(row).toMatchObject({ key: 'code-1700', lineId: null, code: '1700', origin: 'template' }); }); }); describe('cellFlags — внимание только по needs_review', () => { it('низкая уверенность и is_recognized=false без needs_review — не внимание', () => { expect(cellFlags(v('a', 'c', 0, { confidence: '50', is_recognized: false, needs_review: false })).attention).toBe(false); }); it('needs_review=true — внимание, даже при уверенности 100', () => { expect(cellFlags(v('a', 'c', 0, { confidence: '100', needs_review: true })).attention).toBe(true); }); it('is_confirmed — подтверждена, уверенность не меняется', () => { expect(cellFlags(v('a', 'c', 0, { is_confirmed: true })).confirmed).toBe(true); }); it('undefined — все флаги false', () => { expect(cellFlags(undefined)).toEqual({ attention: false, modified: false, confirmed: false }); }); it('пустая достроенная ячейка не требует внимания', () => { expect(cellFlags(v(null, 'c', 0, { amount: null, confidence: null, is_recognized: null }))).toEqual({ attention: false, modified: false, confirmed: false, }); }); it('исправлено', () => { expect(cellFlags(v('a', 'c', 0, { is_modified: true })).modified).toBe(true); }); }); describe('draftToPatch', () => { const rows = buildStatementGrid({ ...statement, lines: [...statement.lines, { id: null, origin: 'template', line_code: '1700', line_name: 'БАЛАНС', values: [] }], } as unknown as StatementOut).rows; it('строка из БД — line_id, достроенная — line_code', () => { expect(draftToPatch({ [cellKey('l1', 'c1')]: '10.5', [cellKey('code-1700', 'c1')]: null }, rows)).toEqual([ { line_id: 'l1', column_id: 'c1', amount: '10.5' }, { line_code: '1700', column_id: 'c1', amount: null }, ]); }); it('неизвестная строка — ошибка, а не тихий пропуск', () => { expect(() => draftToPatch({ [cellKey('ghost', 'c1')]: '1' }, rows)).toThrow(); }); }); describe('applyDraftInput', () => { const parse = (s: string): { ok: true; value: string | null } | { ok: false } => { const n = s.replace(/\s/g, '').replace(',', '.'); if (n === '') { return { ok: true, value: null }; } return /^-?\d+(\.\d+)?$/.test(n) ? { ok: true, value: n } : { ok: false }; }; it('изменение попадает в черновик', () => { expect(applyDraftInput({}, 'k', '5', '1.00', parse)).toEqual({ k: '5' }); }); it('возврат к исходному убирает ключ (с учётом нулей дроби)', () => { expect(applyDraftInput({ k: '5' }, 'k', '1', '1.00', parse)).toEqual({}); expect(applyDraftInput({ k: '5' }, 'k', '1,0', '1.00', parse)).toEqual({}); }); it('пустое при пустом исходном — не изменение', () => { expect(applyDraftInput({ k: '5' }, 'k', '', null, parse)).toEqual({}); }); it('неверный формат остаётся в черновике', () => { expect(applyDraftInput({}, 'k', '12,3,4', '1.00', parse)).toEqual({ k: '12,3,4' }); }); }); describe('parent_code и пустые строки детализации', () => { it('buildStatementGrid переносит parent_code', () => { const withChild = { ...statement, lines: [{ id: 'l2', origin: 'source', line_code: '11101', line_name: 'п', parent_code: '1110', values: [] }, ...statement.lines], } as unknown as StatementOut; const grid = buildStatementGrid(withChild); expect(grid.rows[0].parentCode).toBe('1110'); expect(grid.rows[1].parentCode).toBeNull(); // поля нет в ответе (старый сервер) — не подстрока }); const cell = (extra: Partial) => v('v', 'c1', 0, { amount: null, ...extra }); const gridRow = (key: string, parentCode: string | null, cells: GridRow['cells']): GridRow => ({ key, lineId: key, code: key, name: null, origin: 'source', parentCode, cells, referenceName: null, lineStatus: 'ok', rowIndex: null, parentRowIndex: null, codeText: key, codeConfidence: null, needsReview: false, isConfirmed: false, codeModified: false, }); it('пустая подстрока: все ячейки без суммы и без строки; родитель и нулевая сумма — не пустые', () => { const rows = [ gridRow('11101', '1110', { c1: cell({}), c2: cell({}) }), gridRow('11102', '1110', {}), gridRow('11103', '1110', { c1: cell({ amount: '0.00' }) }), gridRow('11104', '1110', { c1: cell({ string_value: 'нет' }) }), gridRow('11105', '1110', { c1: cell({ amount: '5.00' }) }), gridRow('1110', null, { c1: cell({}) }), ]; expect([...emptySublineKeys(rows, {})]).toEqual(['11101', '11102']); }); it('подстрока с правкой в черновике не считается пустой', () => { const rows = [gridRow('11101', '1110', { c1: cell({}) }), gridRow('11102', '1110', { c1: cell({}) })]; expect([...emptySublineKeys(rows, { '11101:c1': '7' })]).toEqual(['11102']); }); });