From 9a8ae0a5a3f23c4a87384a9f110ff50ab60d5f57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Wed, 26 Aug 2026 16:36:52 +0300 Subject: [PATCH] =?UTF-8?q?test:=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D1=83=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=82=D0=B5=D1=81=D1=82=D1=8B=20deploy/tests=20(?= =?UTF-8?q?=D1=81=D1=81=D1=8B=D0=BB=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D0=B0=D1=80=D1=83=D1=8E=20compare/-=D0=B0?= =?UTF-8?q?=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA=D1=82=D1=83=D1=80=D1=83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/conftest.py | 42 -- deploy/tests.js | 532 ------------------ deploy/tests/__init__.py | 0 deploy/tests/integration/__init__.py | 0 deploy/tests/pipeline/__init__.py | 0 .../tests/pipeline/test_testgen_pipeline.py | 365 ------------ deploy/tests/test_drhider.py | 162 ------ deploy/tests/unit/__init__.py | 0 deploy/tests/unit/test_classify_batch.py | 62 -- deploy/tests/unit/test_contracts.py | 110 ---- deploy/tests/unit/test_llm_repo.py | 59 -- 11 files changed, 1332 deletions(-) delete mode 100644 deploy/conftest.py delete mode 100644 deploy/tests.js delete mode 100644 deploy/tests/__init__.py delete mode 100644 deploy/tests/integration/__init__.py delete mode 100644 deploy/tests/pipeline/__init__.py delete mode 100644 deploy/tests/pipeline/test_testgen_pipeline.py delete mode 100644 deploy/tests/test_drhider.py delete mode 100644 deploy/tests/unit/__init__.py delete mode 100644 deploy/tests/unit/test_classify_batch.py delete mode 100644 deploy/tests/unit/test_contracts.py delete mode 100644 deploy/tests/unit/test_llm_repo.py diff --git a/deploy/conftest.py b/deploy/conftest.py deleted file mode 100644 index 584951c..0000000 --- a/deploy/conftest.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Shared fixtures for pytest — contracts-flask decoupling tests.""" -import pytest -import sys -import os - -# Ensure deploy/ is on path for imports -sys.path.insert(0, os.path.dirname(__file__)) - -from compare.llm_client import FakeLLMClient -from repository import MemRepository - - -@pytest.fixture -def fake_llm(): - """Fake LLM client with pre-registered responses.""" - client = FakeLLMClient() - client.add("default", '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}') - return client - - -@pytest.fixture -def mem_repo(): - """In-memory repository for unit tests.""" - return MemRepository() - - -@pytest.fixture -def sample_docx_bytes(): - """Minimal test bytes — not a real DOCX, just for multipart tests.""" - return b"FAKE_DOCX_CONTENT" - - -@pytest.fixture -def sample_classify_response(): - """Sample LLM classify response.""" - return { - "doc_type": "contract", - "own_number": "03700_1", - "parent_number": None, - "doc_date": "2026-02-01", - "counterparty": "ЗАО XXX001", - } diff --git a/deploy/tests.js b/deploy/tests.js deleted file mode 100644 index 8c5bc8f..0000000 --- a/deploy/tests.js +++ /dev/null @@ -1,532 +0,0 @@ -/** - * 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: '
результаты сравнения
' - } -}; - -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 для ошибочной секции'); - -// Неизвестный тип события — не должен падать -applyCompareEvent(sections, { type: 'unknown_type', supplement_id: 's3' }); -assert(sections['s3'] === undefined, 'неизвестный тип: секция НЕ создана'); - -// llm_done для несуществующей секции — не должен падать -applyCompareEvent(sections, { type: 'llm_done', supplement_id: 's99', ops_count: 1 }); - -// apply_error (должен работать как extract_error) -applyCompareEvent(sections, { type: 'apply_error', supplement_id: 's4', filename: 'apply_err.docx', error: 'apply boom' }); -eq(sections['s4'].status, 'error', 'apply_error: секция с ошибкой создана'); -eq(sections['s4'].error, 'apply boom', 'apply_error: текст ошибки'); - -// applied без summary -applyCompareEvent(sections, { type: 'applied', supplement_id: 's1', ops: [] }); -eq(sections['s1'].status, 'applied', 'applied без summary: status ок'); -eq(sections['s1'].ops.length, 0, 'applied с пустыми ops'); - -// ── Тесты: 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', 'дата'); - -// Пустые ops -var emptyTable = renderCompareOpsTable([]); -contains(emptyTable, '', 'пустые ops: tbody есть'); - -// ops с null new_row -var nullRow = renderCompareOpsTable([{ action: 'ADD' }]); -contains(nullRow, '', '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('