/** * tests.js — Тесты чистых функций (Фазы 0-4, decoupling-final-plan.md). * * Запуск: node tests.js * * Проверяет: statusToHTML, applyParseResult, reconcileSelection, * renderGroupCard, renderGroupCardDone, renderUnresolvedCard, * applyCompareEvent, renderCompareSectionHeader, renderCompareOpsTable, * renderCompareSectionBody. */ // ── Моки глобальных переменных ────────────────────────────── // Браузерные глобалы, нужные модулям при загрузке global.window = global; // window.* присваивания // Мок document global.document = { getElementById: function(id) { return null; }, createElement: function(tag) { return { style: {}, classList: { add: function(){}, remove: function(){} }, innerHTML: '', textContent: '', appendChild: function(){}, querySelector: function(){ return null; }, querySelectorAll: function(){ return []; }, addEventListener: function(){}, parentNode: { insertBefore: function(){} }, nextSibling: null }; }, querySelector: function() { return null; } }; // Мок crypto.randomUUID global.crypto = { randomUUID: function() { return 'test-uuid-' + Date.now(); } }; // Мок lucide global.lucide = { createIcons: function(){} }; // Глобальные переменные приложения global.fileInput = { disabled: false, value: '', addEventListener: function(){}, files: [] }; global.fileTable = { innerHTML: '' }; global.VM_API = 'https://contracts.kube5s.ru'; global.UPLOAD_URL = VM_API + '/upload'; global.CONVERT_URL = VM_API + '/convert-doc'; global.UNZIP_URL = VM_API + '/unzip-upload'; global.SITE_URL = ''; var state = { files: [], contractId: null, batchId: 'test-batch-id', groups: null, _activeCompare: { es: null, timer: null }, ui: { steps: { upload: '○', classify: '○', groups: '○', compare: '○' } } }; // Мок escHtml (из app_utils.js) global.escHtml = function(s) { if (s == null) return ''; return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }; var passed = 0, failed = 0; function assert(cond, msg) { if (cond) { passed++; } else { console.log(' FAIL: ' + msg); failed++; } } function eq(actual, expected, msg) { if (actual === expected) { passed++; } else { console.log(' FAIL: ' + msg + ' — expected ' + JSON.stringify(expected) + ', got ' + JSON.stringify(actual)); failed++; } } function contains(str, substr, msg) { if (str.indexOf(substr) !== -1) { passed++; } else { console.log(' FAIL: ' + msg + ' — string does not contain "' + substr + '"'); console.log(' got: ' + str.substring(0, 200)); failed++; } } // ── Загружаем модули ───────────────────────────────────────── // Модули используют function declaration (глобальные в браузере). // В Node.js загружаем через eval в глобальном контексте. var fs = require('fs'); function loadModule(path) { var code = fs.readFileSync(path, 'utf-8'); // (0, eval) — indirect eval, выполняется в глобальном скоупе (не strict) (0, eval)(code); } console.log('=== Загрузка модулей ==='); loadModule('./files.js'); console.log(' files.js — OK'); loadModule('./groups.js'); console.log(' groups.js — OK'); loadModule('./compare.js'); console.log(' compare.js — OK'); // ── Тесты: statusToHTML ────────────────────────────────────── console.log('\n=== statusToHTML ==='); eq(statusToHTML(null), '', 'null → пусто'); eq(statusToHTML({}), '', 'пустой объект → пусто'); eq(statusToHTML({ kind: '' }), '', 'пустой kind → пусто'); eq(statusToHTML({ kind: 'uploading', pct: 0 }), '↑ 0%', 'uploading 0%'); eq(statusToHTML({ kind: 'uploading', pct: 75 }), '↑ 75%', 'uploading 75%'); eq(statusToHTML({ kind: 'uploaded' }), '✓', 'uploaded'); eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping'); eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing'); eq(statusToHTML({ kind: 'parsed', count: 5 }), '✓ 5 эл.', 'parsed без elapsed'); eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '✓ 5 эл. (2.3с)', 'parsed с elapsed'); eq(statusToHTML({ kind: 'error', text: 'тест' }), '✗ тест', 'error с текстом'); eq(statusToHTML({ kind: 'error' }), '✗ Неизвестная ошибка', 'error без текста'); // Краевые случаи eq(statusToHTML(undefined), '', 'undefined → пусто'); eq(statusToHTML({ kind: 'uploading' }), '↑ 0%', 'uploading без pct → 0%'); eq(statusToHTML({ kind: 'uploading', pct: -5 }), '↑ -5%', 'uploading отрицательный pct'); eq(statusToHTML({ kind: 'uploading', pct: 100 }), '↑ 100%', 'uploading 100%'); eq(statusToHTML({ kind: 'parsed', count: 0 }), '✓ 0 эл.', 'parsed count=0'); eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '✓ 5 эл. (0с)', 'parsed elapsed=0'); eq(statusToHTML({ kind: 'error', text: '' }), '✗ Неизвестная ошибка', 'error с пустым текстом → дефолт'); eq(statusToHTML({ kind: 'unknown_kind' }), '', 'неизвестный kind → пусто'); // ── Тесты: applyParseResult ────────────────────────────────── console.log('\n=== applyParseResult ==='); // parsed success var e1 = {}; applyParseResult(e1, { status: 'parsed', element_count: 10 }, '1.5'); assert(e1.parsed === true, 'parsed=true'); assert(e1.parseInfo.element_count === 10, 'parseInfo сохранён'); eq(e1.status.kind, 'parsed', 'status.kind=parsed'); eq(e1.status.count, 10, 'status.count=10'); eq(e1.status.elapsed, '1.5', 'status.elapsed=1.5'); // parsed without elapsed var e2 = {}; applyParseResult(e2, { status: 'parsed', element_count: 3 }); eq(e2.status.kind, 'parsed', 'без elapsed: kind=parsed'); eq(e2.status.count, 3, 'без elapsed: count=3'); assert(e2.status.elapsed === undefined, 'без elapsed: elapsed отсутствует'); // error var e3 = {}; applyParseResult(e3, { status: 'error', error: 'битый PDF' }); eq(e3.status.kind, 'error', 'error: kind=error'); eq(e3.status.text, 'битый PDF', 'error: text сохранён'); eq(e3.parseInfo.error, 'битый PDF', 'error: parseInfo сохранён'); // null parsed (неизвестная ошибка) var e4 = {}; applyParseResult(e4, null); eq(e4.status.kind, 'error', 'null: kind=error'); eq(e4.status.text, 'Неизвестная ошибка', 'null: дефолтный текст'); // parsed с пустыми полями var e5 = {}; applyParseResult(e5, { status: 'parsed' }); eq(e5.status.kind, 'parsed', 'parsed без element_count: kind=parsed'); eq(e5.status.count, undefined, 'parsed без element_count: count=undefined'); // parsed с element_count=0 var e6 = {}; applyParseResult(e6, { status: 'parsed', element_count: 0 }); eq(e6.status.count, 0, 'parsed element_count=0'); // error без текста ошибки var e7 = {}; applyParseResult(e7, { status: 'error' }); eq(e7.status.text, 'ошибка парсинга', 'error без текста: дефолт "ошибка парсинга"'); // entry с уже существующими полями (не должны мешать) var e8 = { existing: true, parsed: false }; applyParseResult(e8, { status: 'parsed', element_count: 7 }, '3.0'); assert(e8.existing === true, 'старое поле не затёрто'); eq(e8.status.count, 7, 'новые данные поверх старых'); // ── Тесты: reconcileSelection ──────────────────────────────── console.log('\n=== reconcileSelection ==='); var files = [ { name: 'a.docx' }, { name: 'b.pdf' }, { name: 'c.docx' } ]; var newFiles = [ { name: 'a.docx' }, { name: 'c.docx' }, { name: 'd.pdf' } ]; var result = reconcileSelection(files, newFiles); eq(result.length, 2, 'должно остаться 2 файла'); eq(result[0].name, 'a.docx', 'a.docx остался'); eq(result[1].name, 'c.docx', 'c.docx остался'); // Исходный массив не мутирован eq(files.length, 3, 'исходный массив не мутирован'); // Пустые входы var emptyResult = reconcileSelection([], []); eq(emptyResult.length, 0, 'пустые массивы → пусто'); // Все совпадают var allMatch = reconcileSelection([{name:'a'}], [{name:'a'}]); eq(allMatch.length, 1, 'все совпадают → 1'); // Ни один не совпадает var noMatch = reconcileSelection([{name:'a'},{name:'b'}], [{name:'c'},{name:'d'}]); eq(noMatch.length, 0, 'нет совпадений → пусто'); // Дубликаты имён в newFiles (должен работать — Set) var dupNames = [{name:'a'}, {name:'a'}, {name:'b'}]; var dupResult = reconcileSelection([{name:'a'},{name:'b'}], dupNames); eq(dupResult.length, 2, 'дубликаты в newFiles — Set обрабатывает'); // ── Тесты: renderGroupCard ─────────────────────────────────── console.log('\n=== renderGroupCard ==='); var group = { contract_number: '123', counterparty: 'ООО Тест', documents: [ { doc_type: 'contract', filename: 'договор.docx', doc_date: '2024-01-15' }, { doc_type: 'supplement', filename: 'дс1.docx' } ] }; var html = renderGroupCard(group, 0); contains(html, 'Договор №123', 'номер договора'); contains(html, 'ООО Тест', 'контрагент'); contains(html, 'договор', 'тип contract → договор'); contains(html, 'допсоглашение', 'тип supplement → допсоглашение'); contains(html, '2024-01-15', 'дата'); contains(html, 'data-gi="0"', 'data-gi атрибут'); contains(html, 'Сравнить эту группу', 'кнопка сравнения'); // НЕ должно быть ✓ Готово assert(html.indexOf('✓ Готово') === -1, 'нет "✓ Готово" для необработанной'); // Группа с compare.running var groupRunning = { contract_number: '456', counterparty: 'ЗАО Бег', documents: [{ doc_type: 'contract', filename: 'дог.docx' }], compare: { status: 'running' } }; var htmlRunning = renderGroupCard(groupRunning, 1); contains(htmlRunning, 'disabled', 'кнопка disabled при running'); // Без контрагента var groupNoCP = { contract_number: '001', documents: [] }; var htmlNoCP = renderGroupCard(groupNoCP, 5); contains(htmlNoCP, 'контрагент не определён', 'без counterparty: заглушка'); // Неизвестный doc_type var groupUnknown = { contract_number: 'X', counterparty: 'Y', documents: [{ doc_type: 'unknown_type', filename: 'f.docx' }] }; var htmlUnknown = renderGroupCard(groupUnknown, 6); contains(htmlUnknown, 'unknown_type', 'неизвестный тип: выводится как есть'); // Пустой список документов var groupEmpty = { contract_number: 'E', counterparty: 'Empty', documents: [] }; var htmlEmpty = renderGroupCard(groupEmpty, 7); contains(htmlEmpty, 'Договор №E', 'пустые документы: карточка рендерится'); contains(htmlEmpty, 'Сравнить', 'пустые документы: кнопка есть'); // ── Тесты: renderGroupCardDone ─────────────────────────────── console.log('\n=== renderGroupCardDone ==='); var groupDone = { contract_number: '789', counterparty: 'ИП Готово', documents: [ { doc_type: 'contract', filename: 'base.docx' }, { doc_type: 'specification', filename: 'spec.docx' } ], compare: { status: 'done', totalTime: '12.5с', bodyHTML: '
| ', 'null new_row: пустые ячейки (не падает)'); // ops с неизвестным action (без класса) var unknownAct = renderCompareOpsTable([{ action: 'MERGE', new_row: {} }]); assert(unknownAct.indexOf('diff-added') === -1, 'MERGE: нет diff-added'); assert(unknownAct.indexOf('diff-deleted') === -1, 'MERGE: нет diff-deleted'); assert(unknownAct.indexOf('diff-changed') === -1, 'MERGE: нет diff-changed'); // ── Тесты: renderCompareSectionBody ────────────────────────── console.log('\n=== renderCompareSectionBody ==='); eq(renderCompareSectionBody(null), '', 'null → пусто'); eq(renderCompareSectionBody({}), '', 'пустой → пусто'); eq(renderCompareSectionBody({ status: 'llm_done' }), '', 'llm_done → пусто'); var secApplied = { status: 'applied', summary: { added: 3, updated: 1, deleted: 0, unresolved: 2 }, ops: [{ action: 'UPDATE', new_row: { name: 'Стойка 42U' } }] }; var bodyHtml = renderCompareSectionBody(secApplied); contains(bodyHtml, '+3', 'added=3'); contains(bodyHtml, '~1', 'updated=1'); contains(bodyHtml, '-0', 'deleted=0'); contains(bodyHtml, '?2', 'unresolved=2'); contains(bodyHtml, 'diff-changed', 'UPDATE → diff-changed'); // Без unresolved var secNoUnresolved = { status: 'applied', summary: { added: 1, updated: 0, deleted: 0 }, ops: [] }; var bodyNoUnr = renderCompareSectionBody(secNoUnresolved); assert(bodyNoUnr.indexOf('?') === -1, 'без unresolved: нет знака ?'); // Отрицательные значения summary (не должно быть, но не падать) var secNeg = { status: 'applied', summary: { added: -1, updated: 0, deleted: 0 }, ops: [{ action: 'ADD', new_row: {} }] }; var bodyNeg = renderCompareSectionBody(secNeg); contains(bodyNeg, '+-1', 'отрицательные summary: рендерится без ошибок'); // ops с частичным new_row (не все поля) var secPartial = { status: 'applied', summary: { added: 1 }, ops: [ { action: 'ADD', new_row: { name: 'Только имя' } } ]}; var bodyPartial = renderCompareSectionBody(secPartial); contains(bodyPartial, 'Только имя', 'частичный new_row: имя есть'); // Проверим что нет undefined в выводе assert(bodyPartial.indexOf('undefined') === -1, 'нет undefined в выводе'); // ── Тесты: escHtml ─────────────────────────────────────────── console.log('\n=== escHtml ==='); eq(escHtml(null), '', 'escHtml null → пусто'); eq(escHtml(undefined), '', 'escHtml undefined → пусто'); eq(escHtml('hello'), 'hello', 'escHtml обычный текст'); eq(escHtml(' |