533 lines
25 KiB
JavaScript
533 lines
25 KiB
JavaScript
/**
|
||
* 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 без текста');
|
||
|
||
// Краевые случаи
|
||
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 }), '<span class="status-ok">✓ 0 эл.</span>', 'parsed count=0');
|
||
eq(statusToHTML({ kind: 'parsed', count: 5, elapsed: '0' }), '<span class="status-ok">✓ 5 эл. (0с)</span>', 'parsed elapsed=0');
|
||
eq(statusToHTML({ kind: 'error', text: '' }), '<span class="status-err">✗ Неизвестная ошибка</span>', '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: '<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 для ошибочной секции');
|
||
|
||
// Неизвестный тип события — не должен падать
|
||
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, '<table', 'пустые ops: таблица рендерится');
|
||
contains(emptyTable, '<tbody>', 'пустые ops: tbody есть');
|
||
|
||
// ops с null new_row
|
||
var nullRow = renderCompareOpsTable([{ action: 'ADD' }]);
|
||
contains(nullRow, '<td></td>', '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('<script>'), '<script>', 'escHtml экранирует < >');
|
||
eq(escHtml('a&b'), 'a&b', 'escHtml экранирует &');
|
||
eq(escHtml('"quote"'), '"quote"', 'escHtml экранирует кавычки');
|
||
eq(escHtml(123), '123', 'escHtml число → строка');
|
||
|
||
// ── Дымные тесты API (бэкенд) ────────────────────────────────
|
||
console.log('\n=== API smoke tests ===');
|
||
|
||
var http = require('http');
|
||
var https = require('https');
|
||
|
||
function apiTest(method, url, cb) {
|
||
var mod = url.startsWith('https') ? https : http;
|
||
var req = mod.request(url, { method: method, timeout: 5000, rejectUnauthorized: false }, function(res) {
|
||
var body = '';
|
||
res.on('data', function(c) { body += c; });
|
||
res.on('end', function() { cb(null, res.statusCode, body); });
|
||
});
|
||
req.on('error', function(e) { cb(e.message); });
|
||
req.on('timeout', function() { req.destroy(); cb('timeout'); });
|
||
req.end();
|
||
}
|
||
|
||
// Эти тесты асинхронные — собираем результаты
|
||
var apiTests = [];
|
||
function addApiTest(name, method, url, check) {
|
||
apiTests.push({ name: name, method: method, url: url, check: check });
|
||
}
|
||
|
||
addApiTest('GET / (главная)', 'GET', 'https://contracts.kube5s.ru/', function(code, body) {
|
||
assert(code === 200, 'главная: HTTP 200 — got ' + code);
|
||
contains(body, '<!DOCTYPE html>', 'главная: HTML');
|
||
});
|
||
|
||
addApiTest('POST /api/cleanup', 'POST', 'https://contracts.kube5s.ru/api/cleanup', function(code, body) {
|
||
// cleanup может вернуть 200 или 500 (если БД недоступна) — оба норм для дымного теста
|
||
assert(code >= 200 && code < 600, 'cleanup: ответ получен — ' + code);
|
||
});
|
||
|
||
addApiTest('GET /static/state.js', 'GET', 'https://contracts.kube5s.ru/static/state.js', function(code, body) {
|
||
eq(code, 200, 'state.js: HTTP 200 — got ' + code);
|
||
contains(body, 'Центральное состояние', 'state.js: содержит описание');
|
||
});
|
||
|
||
addApiTest('GET /static/files.js', 'GET', 'https://contracts.kube5s.ru/static/files.js', function(code) {
|
||
eq(code, 200, 'files.js: HTTP 200 — got ' + code);
|
||
});
|
||
|
||
addApiTest('GET /static/groups.js', 'GET', 'https://contracts.kube5s.ru/static/groups.js', function(code) {
|
||
eq(code, 200, 'groups.js: HTTP 200 — got ' + code);
|
||
});
|
||
|
||
addApiTest('GET /static/compare.js', 'GET', 'https://contracts.kube5s.ru/static/compare.js', function(code) {
|
||
eq(code, 200, 'compare.js: HTTP 200 — got ' + code);
|
||
});
|
||
|
||
addApiTest('GET /static/app.js', 'GET', 'https://contracts.kube5s.ru/static/app.js', function(code) {
|
||
eq(code, 200, 'app.js: HTTP 200 — got ' + code);
|
||
});
|
||
|
||
// Запускаем API тесты последовательно (чтобы не зафлудить)
|
||
var apiIdx = 0;
|
||
function runNextApiTest() {
|
||
if (apiIdx >= apiTests.length) {
|
||
// Все API тесты готовы — выводим итоги
|
||
printResults();
|
||
return;
|
||
}
|
||
var t = apiTests[apiIdx];
|
||
apiTest(t.method, t.url, function(err, code, body) {
|
||
if (err) {
|
||
console.log(' FAIL: ' + t.name + ' — ' + err);
|
||
failed++;
|
||
} else {
|
||
t.check(code, body || '');
|
||
}
|
||
apiIdx++;
|
||
runNextApiTest();
|
||
});
|
||
}
|
||
|
||
function printResults() {
|
||
console.log('\n========================================');
|
||
console.log('PASS: ' + passed);
|
||
console.log('FAIL: ' + failed);
|
||
console.log('========================================');
|
||
if (failed > 0) process.exit(1);
|
||
}
|
||
|
||
// Начинаем с API тестов после синхронных
|
||
console.log(' (асинхронные — ждём...)');
|
||
runNextApiTest();
|