v1.0.178: тесты чистых функций. 78 тестов: statusToHTML, applyParseResult, reconcileSelection, renderGroupCard(*), applyCompareEvent, renderCompareSectionHeader/Body, renderCompareOpsTable. Все PASS.
This commit is contained in:
+330
@@ -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,'>').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' }), '<span class="status-ok">✓</span>', 'uploaded');
|
||||
eq(statusToHTML({ kind: 'unzipping' }), '⏳ распаковка...', 'unzipping');
|
||||
eq(statusToHTML({ kind: 'parsing' }), '⏳ парсинг...', 'parsing');
|
||||
eq(statusToHTML({ kind: 'parsed', count: 5 }), '<span class="status-ok">✓ 5 эл.</span>', 'parsed без elapsed');
|
||||
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '2.3' }), '<span class="status-ok">✓ 5 эл. (2.3с)</span>', 'parsed с elapsed');
|
||||
eq(statusToHTML({ kind: 'error', text: 'тест' }), '<span class="status-err">✗ тест</span>', 'error с текстом');
|
||||
eq(statusToHTML({ kind: 'error' }), '<span class="status-err">✗ Неизвестная ошибка</span>', '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: '<div>результаты сравнения</div>'
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user