From 691549223f5b34aa0eb7c004908efab55b380123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Thu, 25 Jun 2026 09:28:15 +0400 Subject: [PATCH] =?UTF-8?q?v1.0.178:=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20?= =?UTF-8?q?=D1=87=D0=B8=D1=81=D1=82=D1=8B=D1=85=20=D1=84=D1=83=D0=BD=D0=BA?= =?UTF-8?q?=D1=86=D0=B8=D0=B9.=2078=20=D1=82=D0=B5=D1=81=D1=82=D0=BE=D0=B2?= =?UTF-8?q?:=20statusToHTML,=20applyParseResult,=20reconcileSelection,=20r?= =?UTF-8?q?enderGroupCard(*),=20applyCompareEvent,=20renderCompareSectionH?= =?UTF-8?q?eader/Body,=20renderCompareOpsTable.=20=D0=92=D1=81=D0=B5=20PAS?= =?UTF-8?q?S.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/tests.js | 330 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 deploy/tests.js diff --git a/deploy/tests.js b/deploy/tests.js new file mode 100644 index 0000000..2fdc15f --- /dev/null +++ b/deploy/tests.js @@ -0,0 +1,330 @@ +/** + * 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 без текста'); + +// ── Тесты: 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: дефолтный текст'); + +// ── Тесты: 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, 'исходный массив не мутирован'); + +// ── Тесты: 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'); + +// ── Тесты: 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: '
результаты сравнения
' + } +}; + +var htmlDone = renderGroupCardDone(groupDone, 2); +contains(htmlDone, '✓ Готово (12.5с)', '✓ Готово с временем'); +contains(htmlDone, 'cmpArrow_2', 'стрелка сворачивания'); +contains(htmlDone, 'спецификация', 'тип specification → спецификация'); +contains(htmlDone, 'результаты сравнения', 'bodyHTML вставлен'); +contains(htmlDone, 'data-gi="2"', 'data-gi на заголовке'); + +// ── Тесты: renderUnresolvedCard ────────────────────────────── +console.log('\n=== renderUnresolvedCard ==='); + +var unresolved = { + contract_number: '__unresolved__', + documents: [ + { doc_type: 'other', filename: 'unknown.docx', parent_number: '999' }, + { doc_type: 'contract', filename: 'bad.docx', classify_status: 'failed', error_message: 'LLM error' } + ] +}; + +var htmlUnres = renderUnresolvedCard(unresolved); +contains(htmlUnres, 'Не распознано', 'заголовок нераспознанных'); +contains(htmlUnres, 'нет базового договора №999', 'причина: нет базового'); +contains(htmlUnres, 'ошибка классификации: LLM error', 'причина: ошибка классификации'); + +// ── Тесты: applyCompareEvent ───────────────────────────────── +console.log('\n=== applyCompareEvent ==='); + +var sections = {}; + +// extract_start +applyCompareEvent(sections, { type: 'extract_start', supplement_id: 's1', filename: 'test.docx' }); +assert(sections['s1'] !== undefined, 'секция создана'); +eq(sections['s1'].filename, 'test.docx', 'filename сохранён'); +eq(sections['s1'].status, 'extracting', 'status=extracting'); + +// llm_done +applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's1', ops_count: 15, mode: 'llm', time_s: 3.2 }); +eq(sections['s1'].status, 'llm_done', 'status=llm_done'); +eq(sections['s1'].ops_count, 15, 'ops_count=15'); +eq(sections['s1'].mode, 'llm', 'mode=llm'); +eq(sections['s1'].time_s, 3.2, 'time_s=3.2'); + +// applied +applyCompareEvent(sections, { + type: 'applied', supplement_id: 's1', + summary: { added: 5, updated: 2, deleted: 1 }, + ops: [{ action: 'ADD', new_row: { name: 'Услуга 1' } }] +}); +eq(sections['s1'].status, 'applied', 'status=applied'); +eq(sections['s1'].summary.added, 5, 'summary.added=5'); +eq(sections['s1'].ops.length, 1, 'ops.length=1'); + +// extract_error на существующей секции +applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's1', error: 'parse failed' }); +eq(sections['s1'].status, 'error', 'после ошибки: status=error'); +eq(sections['s1'].error, 'parse failed', 'текст ошибки'); + +// extract_error на НЕсуществующей секции +applyCompareEvent(sections, { type: 'extract_error', supplement_id: 's2', filename: 'bad.docx', error: 'boom' }); +eq(sections['s2'].status, 'error', 'новая секция с ошибкой'); +eq(sections['s2'].filename, 'bad.docx', 'filename для ошибочной секции'); + +// ── Тесты: renderCompareSectionHeader ──────────────────────── +console.log('\n=== renderCompareSectionHeader ==='); + +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'extracting' }), '⏳', 'extracting: ⏳'); +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '✓', 'llm_done: ✓'); +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'llm_done', ops_count: 5, mode: 'llm', time_s: 2 }), '5 оп.', 'llm_done: ops_count'); +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), '✗', 'error: ✗'); +contains(renderCompareSectionHeader({ filename: 'f.docx', status: 'error', error: 'fail' }), 'fail', 'error: текст'); + +// ── Тесты: renderCompareOpsTable ───────────────────────────── +console.log('\n=== renderCompareOpsTable ==='); + +var ops = [ + { action: 'ADD', new_row: { name: 'Стойка', price: 100, qty: 2, sum: 200, date_start: '2024-01-01' } }, + { action: 'DELETE', new_row: { name: 'IP', price: 50, qty: 1, sum: 50, date_start: '' } } +]; +var tableHtml = renderCompareOpsTable(ops); +contains(tableHtml, 'diff-added', 'ADD → diff-added'); +contains(tableHtml, 'diff-deleted', 'DELETE → diff-deleted'); +contains(tableHtml, 'Стойка', 'имя услуги'); +contains(tableHtml, '100', 'цена'); +contains(tableHtml, '2024-01-01', 'дата'); + +// ── Тесты: 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'); + +// ── Итоги ──────────────────────────────────────────────────── +console.log('\n========================================'); +console.log('PASS: ' + passed); +console.log('FAIL: ' + failed); +console.log('========================================'); + +if (failed > 0) { + process.exit(1); +}