test: удалены устаревшие тесты deploy/tests (ссылались на старую compare/-архитектуру)

This commit is contained in:
“Naeel”
2026-08-26 16:36:52 +03:00
parent 87524c54a4
commit 9a8ae0a5a3
11 changed files with 0 additions and 1332 deletions
-42
View File
@@ -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",
}
-532
View File
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
};
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>'), '&lt;script&gt;', 'escHtml экранирует < >');
eq(escHtml('a&b'), 'a&amp;b', 'escHtml экранирует &');
eq(escHtml('"quote"'), '&quot;quote&quot;', '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();
View File
View File
@@ -1,365 +0,0 @@
"""
test_testgen_pipeline.py — Сквозной тест: testgen → parse → textify → LLM-промпт.
Пайплайн (без БД, без HTTP):
1. testgen генерирует DOCX (договор, спека, допник)
2. parse.py парсит байты → elements_json
3. process.py::_elements_to_text() → plain text для LLM
4. llm_prompt.py строит промпт → проверяем структуру
Точка входа: elements_json — именно туда приходят данные после парсинга.
"""
import io
import json
import sys
import os
import pytest
# Добавить пути для импортов
_deploy = os.path.join(os.path.dirname(__file__), "..", "..")
_testgen = os.path.join(_deploy, "..", "..", "testgen")
sys.path.insert(0, _deploy)
sys.path.insert(0, _testgen)
from compare.parse import parse_file
from compare.process import _elements_to_text
# ═══════════════════════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════════════════════
def _build_and_parse(build_fn, ctx):
"""Построить docx через testgen, отдать байты парсеру. Возвращает parsed dict."""
from docx import Document
doc = build_fn(ctx)
buf = io.BytesIO()
doc.save(buf)
return parse_file("test.docx", buf.getvalue())
def _elements_to_dicts(elements):
"""Привести elements к dict для сравнения (убрать лишнее)."""
result = []
for el in elements:
d = {"type": el["type"]}
if el["type"] == "paragraph":
d["text"] = el.get("text", "")
elif el["type"] == "table":
d["row_count"] = len(el.get("rows", []))
d["col_count"] = len(el["rows"][0]) if el.get("rows") else 0
d["first_cell"] = el["rows"][0][0] if el.get("rows") and el["rows"][0] else ""
result.append(d)
return result
# ═══════════════════════════════════════════════════════════════
# Tests
# ═══════════════════════════════════════════════════════════════
class TestGenerateParseTextify:
"""Полный пайплайн: testgen → parse → textify."""
# ── Данные ────────────────────────────────────────────────
@pytest.fixture
def pools(self):
import pools
return pools
@pytest.fixture
def templates(self):
import templates
return templates
@pytest.fixture
def contract_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
return {
"company": c, "number": num, "date": date, "company_idx": ci,
"comment": "Тестовый контракт для пайплайна.",
}
@pytest.fixture
def spec_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
svcs, total, total_str = pools.pick_services(5)
for s in svcs:
s["date_start"] = pools.random_date(2026, 3, 3)
return {
"company": c, "number": num, "date": date, "contract_date": date,
"version": 1, "services": svcs, "total": total, "total_str": total_str,
"label": f"Абонентские услуги с {date}",
"comment": "Тестовая спецификация для пайплайна.",
}
@pytest.fixture
def addendum_ctx(self, pools):
ci = 0
c = pools.COMPANIES[ci]
num = pools.contract_number(ci)
date = pools.random_date()
inst, itot, itot_s = pools.pick_services(2)
mon, mtot, mtot_s = pools.pick_services(3)
return {
"company": c, "number": num, "date": date, "contract_date": pools.random_date(),
"addendum_num": 1,
"services_install": inst, "install_total": itot, "install_total_str": itot_s,
"services_monthly": mon, "monthly_total": mtot, "monthly_total_str": mtot_s,
"comment": "Тестовый допник для пайплайна.",
}
# ── Этап 1: Генерация → парсинг ───────────────────────────
def test_generate_contract_parse_ok(self, templates, contract_ctx):
"""Договор: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_contract, contract_ctx)
assert result["status"] == "parsed", f"Parse failed: {result.get('error')}"
assert result["element_count"] > 0
def test_generate_contract_has_paragraphs(self, templates, contract_ctx):
"""Договор: в elements есть paragraphs."""
result = _build_and_parse(templates.build_contract, contract_ctx)
paragraphs = [e for e in result["elements"] if e["type"] == "paragraph"]
assert len(paragraphs) >= 3, f"Expected >=3 paragraphs, got {len(paragraphs)}"
def test_generate_contract_has_tables(self, templates, contract_ctx):
"""Договор: в elements есть таблица реквизитов."""
result = _build_and_parse(templates.build_contract, contract_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1, f"Expected >=1 table, got {len(tables)}"
def test_generate_spec_parse_ok(self, templates, spec_ctx):
"""Спецификация: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_spec, spec_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_spec_has_services_table(self, templates, spec_ctx):
"""Спецификация: таблица услуг найдена."""
result = _build_and_parse(templates.build_spec, spec_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) >= 1
# Первая таблица — услуги, должна быть НЕ пустой
svc_table = tables[0]
assert len(svc_table["rows"]) >= 2 # заголовок + минимум 1 строка
def test_generate_addendum_parse_ok(self, templates, addendum_ctx):
"""Допсоглашение: генерируется и парсится без ошибок."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
assert result["status"] == "parsed"
assert result["element_count"] > 0
def test_generate_addendum_has_tables(self, templates, addendum_ctx):
"""Допсоглашение: таблицы разовых и абонентских услуг."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
tables = [e for e in result["elements"] if e["type"] == "table"]
# Должно быть минимум 3 таблицы: реквизиты + разовые + абонентские
assert len(tables) >= 3, f"Expected >=3 tables, got {len(tables)}"
# ── Этап 2: elements_json → textify ────────────────────────
def test_textify_contract(self, templates, contract_ctx):
"""Договор after textify содержит ключевые слова."""
result = _build_and_parse(templates.build_contract, contract_ctx)
text = _elements_to_text(result["elements"])
assert "Договор" in text or "ДОГОВОР" in text
assert "НУБЕС" in text
assert len(text) > 200, f"Text too short: {len(text)} chars"
def test_textify_spec_contains_table_markers(self, templates, spec_ctx):
"""Спецификация after textify: пайп-таблицы на месте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
# Должны быть маркеры таблиц (--- Таблица ... ---)
assert "Таблица" in text
# Должны быть пайпы (формат ячеек)
assert "|" in text
# Должны быть названия услуг
for s in spec_ctx["services"]:
# Проверяем первые 30 символов названия
short_name = s["name"][:30]
assert short_name in text, f"Service '{short_name}' not found in textified output"
def test_textify_spec_contains_total(self, templates, spec_ctx):
"""Спецификация: итоговая сумма в тексте."""
result = _build_and_parse(templates.build_spec, spec_ctx)
text = _elements_to_text(result["elements"])
assert "Итого" in text
def test_textify_addendum_structure(self, templates, addendum_ctx):
"""Допсоглашение after textify: структура сохраняется."""
result = _build_and_parse(templates.build_addendum, addendum_ctx)
text = _elements_to_text(result["elements"])
assert "Соглашение" in text or "СОГЛАШЕНИЕ" in text
assert "Инсталляц" in text or "Разовые" in text
assert "Абонентские" in text
# ── Этап 3: elements_json → промпт LLM ─────────────────────
def test_build_extract_prompt_from_elements(self, templates, spec_ctx):
"""Из elements_json спецификации строится extract-промпт.
Пустая current_spec → extract mode (FALLBACK_EXTRACT)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_spec, spec_ctx)
doc_text = _elements_to_text(result["elements"])
# Пустая current_spec = первый документ = extract-промпт
prompt_text, prompt_id = build_prompt([], doc_text)
assert len(prompt_text) > 100
assert "{doc_text}" not in prompt_text # переменная подставлена
assert doc_text in prompt_text # текст документа внутри промпта
assert "JSON" in prompt_text or "json" in prompt_text
def test_build_diff_prompt_from_elements(self, templates, addendum_ctx):
"""Из elements_json допсоглашения строится diff-промпт.
Непустая current_spec → diff mode (FALLBACK_DIFF)."""
from llm_prompt import build_prompt
result = _build_and_parse(templates.build_addendum, addendum_ctx)
doc_text = _elements_to_text(result["elements"])
# Непустая current_spec = не первый документ = diff-промпт
current_spec = [
{"hash": "h1", "name": "Аренда стойко-места", "price": 50000, "qty": 1, "sum": 50000, "date_start": "2026-01-01"},
]
prompt_text, prompt_id = build_prompt(current_spec, doc_text)
assert len(prompt_text) > 100
assert doc_text in prompt_text
assert "UPDATE" in prompt_text or "DELETE" in prompt_text or "ADD" in prompt_text
# ── Этап 4: edge cases ─────────────────────────────────────
def test_parse_empty_docx(self):
"""Пустой docx — парсится без ошибок, но без элементов."""
from docx import Document
doc = Document()
buf = io.BytesIO()
doc.save(buf)
result = parse_file("empty.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 0
def test_parse_docx_text_only(self):
"""DOCX только с текстом — корректно парсится."""
from docx import Document
doc = Document()
doc.add_paragraph("Привет мир")
doc.add_paragraph("Вторая строка")
buf = io.BytesIO()
doc.save(buf)
result = parse_file("text.docx", buf.getvalue())
assert result["status"] == "parsed"
assert result["element_count"] == 2
assert all(e["type"] == "paragraph" for e in result["elements"])
def test_parse_docx_table_only(self):
"""DOCX только с таблицей — корректно парсится."""
from docx import Document
doc = Document()
table = doc.add_table(rows=2, cols=3)
table.rows[0].cells[0].text = "A"
table.rows[0].cells[1].text = "B"
table.rows[0].cells[2].text = "C"
table.rows[1].cells[0].text = "1"
table.rows[1].cells[1].text = "2"
table.rows[1].cells[2].text = "3"
buf = io.BytesIO()
doc.save(buf)
result = parse_file("table.docx", buf.getvalue())
assert result["status"] == "parsed"
tables = [e for e in result["elements"] if e["type"] == "table"]
assert len(tables) == 1
assert tables[0]["rows"] == [["A", "B", "C"], ["1", "2", "3"]]
def test_textify_preserves_table_data(self):
"""Textify сохраняет все данные таблицы (без потерь)."""
elements = [
{"type": "table", "rows": [
["Услуга", "Цена", "Кол-во"],
["Аренда стойки", "50000", "2"],
["IP-адрес", "300", "8"],
]}
]
text = _elements_to_text(elements)
assert "Услуга" in text
assert "Аренда стойки" in text
assert "50000" in text
assert "2" in text
assert "IP-адрес" in text
assert "300" in text
assert "8" in text
# ── Этап 5: валидация manifest-подобной структуры ──────────
def test_contract_elements_structure(self, templates, contract_ctx):
"""Элементы договора имеют правильную структуру."""
result = _build_and_parse(templates.build_contract, contract_ctx)
for el in result["elements"]:
assert "type" in el
assert el["type"] in ("paragraph", "table")
if el["type"] == "paragraph":
assert "text" in el
assert isinstance(el["text"], str)
elif el["type"] == "table":
assert "rows" in el
assert isinstance(el["rows"], list)
assert len(el["rows"]) > 0
assert all(isinstance(row, list) for row in el["rows"])
def test_multiple_contracts_different_content(self, templates, pools):
"""Разные контракты дают разный elements_json."""
import hashlib
results = []
for ci in range(3):
c = pools.COMPANIES[ci]
ctx = {
"company": c, "number": pools.contract_number(ci),
"date": pools.random_date(), "company_idx": ci,
"comment": f"Контракт {ci}",
}
result = _build_and_parse(templates.build_contract, ctx)
# Сериализуем для сравнения
txt = _elements_to_text(result["elements"])
results.append(txt)
# Все три должны быть разными
hashes = [hashlib.md5(t.encode()).hexdigest() for t in results]
assert len(set(hashes)) == 3, f"All 3 contracts produced same text!"
def test_spec_v1_v2_produce_different_tables(self, templates, pools):
"""Спецификации v1 и v2 дают разные таблицы (разные цены/объёмы)."""
c = pools.COMPANIES[0]
base_ctx = {
"company": c, "number": pools.contract_number(0),
"date": pools.random_date(), "contract_date": pools.random_date(),
"version": 1,
"label": "Абонентские услуги",
}
# v1
svcs_v1, tot_v1, tot_str_v1 = pools.pick_services(5)
ctx_v1 = {**base_ctx, "services": svcs_v1, "total": tot_v1, "total_str": tot_str_v1}
r1 = _build_and_parse(templates.build_spec, ctx_v1)
t1 = _elements_to_text(r1["elements"])
# v2 — меняем цены
import copy
svcs_v2 = copy.deepcopy(svcs_v1)
if svcs_v2:
svcs_v2[0]["price"] = round(svcs_v2[0]["price"] * 1.25, 2)
svcs_v2[0]["price_str"] = pools.format_price(svcs_v2[0]["price"])
svcs_v2[0]["sum"] = round(svcs_v2[0]["price"] * svcs_v2[0]["qty"], 2)
svcs_v2[0]["sum_str"] = pools.format_price(svcs_v2[0]["sum"])
tot_v2 = round(sum(s["sum"] for s in svcs_v2), 2)
ctx_v2 = {**base_ctx, "version": 2, "services": svcs_v2, "total": tot_v2, "total_str": pools.format_price(tot_v2)}
r2 = _build_and_parse(templates.build_spec, ctx_v2)
t2 = _elements_to_text(r2["elements"])
assert t1 != t2, "v1 and v2 specs produced identical text!"
-162
View File
@@ -1,162 +0,0 @@
"""
Тест DrHider — много проверок.
Файл: /home/naeel/nubes/contracts/contracts-flask/deploy/tests/test_drhider.py
"""
import sys, os, io, zipfile, json, base64
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from drhider.drhider import obfuscate_files, TwoPassObfuscator, COMPANY_PATTERN, PERSON_PATTERN
TEST_ZIP = "/home/naeel/nubes/contracts/dogovora/примеры_договоров_для_ИИ.zip"
def run(text, desc):
"""Обработать текст и проверить что needle НЕ найдено."""
z, c = obfuscate_files([('t.txt', text.encode(), '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
out = zf.read('t.txt').decode()
csv = zf.read('mapping.csv').decode()
return out, csv
errors = 0
def check(ok, msg):
global errors
if ok:
print(f"{msg}")
else:
print(f"{msg}")
errors += 1
print("=" * 60)
print("DrHider — полный тест")
print("=" * 60)
# ── 1. Телефоны ──
print("\n📞 Телефоны:")
out, csv = run("Звоните +7 (495) 789-41-35 или 8-800-555-35-35", "телефоны")
check("+7 (495) 789-41-35" not in out, "+7 (495) 789-41-35 заменён")
check("8-800-555-35-35" not in out, "8-800-555-35-35 заменён")
check("phone" in csv, "тип phone в CSV")
# ── 2. Телефон не матчит число внутри счёта ──
print("\n📞 Телефон vs номер счёта:")
out, csv = run("Кор/сч 30101810400000000225", "счёт")
check("30101810400000000225" not in out, "номер счёта заменён (ks)")
check("+7" not in out, "нет ложного телефона внутри счёта")
# ── 3. Email ──
print("\n📧 Email:")
out, csv = run("Пишите info@nubes.ru и support@company.org", "email")
check("info@nubes.ru" not in out, "info@nubes.ru заменён")
check("support@company.org" not in out, "support@company.org заменён")
check("email" in csv, "тип email в CSV")
# ── 4. Компании (разные кавычки) ──
print("\n🏢 Компании:")
out, csv = run('ООО "Ромашка" и АО \u201cXXX003\u201d и ЗАО «Тест»', "компании")
check('ООО "Ромашка"' not in out, 'ООО "Ромашка" заменён')
check('АО \u201cXXX003\u201d' not in out, 'АО "XXX003" (unicode) заменён')
check('ЗАО «Тест»' not in out, 'ЗАО «Тест» заменён')
check('company' in csv, 'тип company в CSV')
# ── 5. НУБЕС whitelist ──
print("\n🛡️ НУБЕС whitelist:")
out, csv = run('ООО "НУБЕС" и ООО \u201cНУБЕС\u201d', "НУБЕС")
check('ООО "НУБЕС"' in out, 'НУБЕС (ASCII) НЕ заменён')
check('ООО \u201cНУБЕС\u201d' in out, 'НУБЕС (unicode) НЕ заменён')
# ── 6. ИНН/ОГРН/КПП/БИК ──
print("\n🔢 ИНН/ОГРН/КПП/БИК:")
out, csv = run("ИНН 9706005293, КПП 772401001, ОГРН 1207700098759, БИК 044525225", "реквизиты")
check("9706005293" not in out, "ИНН заменён")
check("772401001" not in out, "КПП заменён")
check("1207700098759" not in out, "ОГРН заменён")
check("044525225" not in out, "БИК заменён")
check("inn_ul" in csv, "inn_ul в CSV")
# ── 7. Расчётный счёт ──
print("\n💳 Расчётный счёт:")
out, csv = run("р/с 40702810738000174030 и Кор/сч 30101810400000000225", "счета")
check("40702810738000174030" not in out, "р/с заменён")
check("30101810400000000225" not in out, "кор/сч заменён")
check("rs" in csv, "rs в CSV")
check("ks" in csv, "ks в CSV")
# ── 8. ФИО (инициалы + полностью) ──
print("\n👤 ФИО:")
out, csv = run("Директор Степаненко В.С. и Иванов Александр Петрович", "ФИО")
check("Степаненко В.С." not in out, "Степаненко В.С. заменён")
check("Иванов Александр Петрович" not in out, "Иванов А.П. заменён")
# ── 9. Паспорт ──
print("\n📄 Паспорт:")
out, csv = run("паспорт 12 34 567890", "паспорт")
check("12 34 567890" not in out, "номер паспорта заменён")
# ── 10. Согласованность (одна сущность → одна замена) ──
print("\n🔄 Согласованность:")
z, c = obfuscate_files([
('a.txt', b'OOO Romashka +79991234567', ''),
('b.txt', b'OOO Romashka +79991234567', ''),
])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
a = zf.read('a.txt').decode()
b = zf.read('b.txt').decode()
check(a == b, f"оба файла одинаковы: {a}")
# ── 11. ZIP внутри ZIP ──
print("\n📦 ZIP внутри:")
with open(TEST_ZIP, 'rb') as f:
raw = f.read()
z, c = obfuscate_files([('test.zip', raw, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
names = zf.namelist()
check(len(names) == 6, f"6 файлов (5 + csv): {len(names)}")
check('mapping.csv' in names, "mapping.csv есть")
check(not any(n.endswith('.zip') for n in names), "нет .zip в выдаче")
# Проверим что docx внутри валидны
for n in names:
if n.endswith('.docx'):
try:
from docx import Document
Document(io.BytesIO(zf.read(n)))
check(True, f"{n} — валидный docx")
except:
check(False, f"{n} — БИТЫЙ docx")
# ── 12. .doc бинарный — не трогаем ──
print("\n📄 .doc бинарный:")
# Создаём фейковый .doc файл (просто бинарные данные)
z, c = obfuscate_files([('old.doc', b'\xD0\xCF\x11\xE0' + b'\x00' * 100, '')])
with zipfile.ZipFile(io.BytesIO(z)) as zf:
doc_content = zf.read('old.doc')
check(len(doc_content) == 104, ".doc сохранён без изменений")
# ── 13. Много файлов ──
print("\n📚 20 файлов:")
many = [('f{}.txt'.format(i), 'OOO Test{} +7999{}'.format(i, i).encode(), '') for i in range(20)]
z, c = obfuscate_files(many)
with zipfile.ZipFile(io.BytesIO(z)) as zf:
check(len(zf.namelist()) == 21, "20 файлов + csv")
# ── 14. COMPANY_PATTERN не жадный ──
print("\n🔤 COMPANY_PATTERN границы:")
m = COMPANY_PATTERN.search("АО Test с дополнительным текстом дальше")
check(m is not None and 'дальше' not in m.group(0), "не захватывает лишний текст")
# ── 15. PERSON_PATTERN только кириллица ──
print("\n🔤 PERSON_PATTERN кириллица:")
m = PERSON_PATTERN.search("Next Generation Cloud service")
check(m is None, "английский не матчится")
m2 = PERSON_PATTERN.search("Иванов Иван Иванович")
check(m2 is not None, "русский матчится")
# ── ИТОГО ──
print(f"\n{'='*60}")
if errors:
print(f"{errors} ошибок")
else:
print("✅ ВСЕ ТЕСТЫ ПРОЙДЕНЫ")
print(f"{'='*60}")
sys.exit(0 if errors == 0 else 1)
View File
-62
View File
@@ -1,62 +0,0 @@
"""Test classify_batch with MemRepository + FakeLLM."""
from compare.classify import classify_batch
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestClassifyBatch:
def test_garbage_by_filename(self):
"""Счёт-фактура отфильтровывается без LLM."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
repo.insert_document("счет-фактура №123.docx", "mime", "data", batch_id="b1")
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["ok"] is True
assert result["garbage"] == 1
assert result["done"] == 1
assert len(llm.calls) == 0 # LLM не вызывался
def test_garbage_by_header(self):
"""Акт сверки в тексте отфильтровывается."""
repo = MemRepository()
llm = FakeLLMClient({"default": '{"doc_type":"contract"}'})
doc = repo.insert_document("документ.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "АКТ СВЕРКИ взаимных расчётов", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["garbage"] == 1
assert len(llm.calls) == 0
def test_llm_classify(self):
"""Договор классифицируется через LLM."""
repo = MemRepository()
llm = FakeLLMClient({
"default": '{"doc_type":"contract","own_number":"03700_1","doc_date":"2026-02-01","counterparty":"ЗАО XXX001"}'
})
doc = repo.insert_document("договор-XXX001.docx", "mime", "data", batch_id="b1")
repo.set_document_parsed(doc["id"], [
{"type": "paragraph", "text": "ДОГОВОР № 03700_1", "style": ""}
])
result = classify_batch("b1", llm_client=llm, repo=repo)
assert result["done"] == 1, f"done={result['done']}, failed={result['failed']}, doc={repo.get_document(doc['id'])}"
assert result["garbage"] == 0
assert len(llm.calls) == 1
updated = repo.get_document(doc["id"])
assert updated["doc_type"] == "contract"
assert updated["own_number"] == "03700_1"
assert updated["counterparty"] == "ЗАО XXX001"
def test_no_pending(self):
"""Пустой батч."""
repo = MemRepository()
result = classify_batch("empty", llm_client=FakeLLMClient(), repo=repo)
assert result["ok"] is False
assert "no pending" in result["error"]
-110
View File
@@ -1,110 +0,0 @@
"""Unit tests — no DB, no network, just pure logic."""
import pytest
from contracts import ParseResult, ClassifyResult, GroupingResult, CompareOp
from compare.upload import parse_multipart
from compare.classify import _is_garbage_by_filename, _is_garbage_by_header
class TestContracts:
"""Dataclass creation and validation."""
def test_parse_result(self):
r = ParseResult(status="parsed", element_count=10, elements=[])
assert r.status == "parsed"
assert r.element_count == 10
def test_classify_from_llm(self):
r = ClassifyResult.from_llm({
"doc_type": "contract",
"own_number": "03700_1",
"doc_date": "2026-02-01",
"counterparty": "ЗАО XXX001",
})
assert r.doc_type == "contract"
assert r.own_number == "03700_1"
assert r.counterparty == "ЗАО XXX001"
def test_grouping_result_empty(self):
r = GroupingResult(contract_number="03700_1")
assert r.contract_number == "03700_1"
assert r.documents == []
def test_compare_op_add(self):
op = CompareOp(action="ADD", new_row={"name": "Тест", "price": 100})
assert op.action == "ADD"
assert op.new_row["price"] == 100
def test_compare_op_from_llm(self):
op = CompareOp.from_llm({
"action": "UPDATE",
"target_id": "r1",
"new_values": {"price": 200},
"comment": "change",
})
assert op.action == "UPDATE"
assert op.new_values["price"] == 200
assert op.comment == "change"
class TestGarbageFilter:
"""Stage 1-2 garbage detection."""
def test_filename_garbage_invoice(self):
assert _is_garbage_by_filename("счет-фактура №123.docx") is True
def test_filename_garbage_act(self):
assert _is_garbage_by_filename("Акт сверки за май.pdf") is True
def test_filename_not_garbage(self):
assert _is_garbage_by_filename("договор-XXX001.docx") is False
assert _is_garbage_by_filename("спецификация услуг.pdf") is False
def test_header_garbage(self):
text = "СЧЕТ-ФАКТУРА № 123 от 01.01.2026\nПоставщик: ООО Ромашка"
assert _is_garbage_by_header(text) is True
def test_header_not_garbage(self):
text = "ДОГОВОР № 03700_1\nг. Москва\n\nИсполнитель обязуется..."
assert _is_garbage_by_header(text) is False
class TestParseMultipart:
"""Pure multipart parsing without HTTP."""
def test_simple_file(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n'
b"Content-Type: application/octet-stream\r\n\r\n"
+ sample_docx_bytes +
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["filename"] == "test.docx"
assert result["file_data"] == sample_docx_bytes
def test_with_contract_id(self, sample_docx_bytes):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="test.docx"\r\n\r\n'
+ sample_docx_bytes +
b"\r\n------testboundary\r\n"
b'Content-Disposition: form-data; name="contract_id"\r\n\r\n'
b"550e8400-e29b-41d4-a716-446655440000"
b"\r\n------testboundary--\r\n"
)
result = parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
assert result["contract_id"] == "550e8400-e29b-41d4-a716-446655440000"
def test_path_traversal_rejected(self):
boundary = b"----testboundary"
body = (
b"------testboundary\r\n"
b'Content-Disposition: form-data; name="files"; filename="../etc/passwd"\r\n\r\n'
b"evil"
b"\r\n------testboundary--\r\n"
)
with pytest.raises(ValueError, match="invalid filename"):
parse_multipart(body, f"multipart/form-data; boundary=----testboundary")
-59
View File
@@ -1,59 +0,0 @@
"""Unit tests for LLM client and Repository — with fakes."""
from compare.llm_client import FakeLLMClient
from repository import MemRepository
class TestFakeLLM:
def test_exact_match(self):
client = FakeLLMClient({"hello": "world"})
assert client.complete("hello") == "world"
def test_partial_match(self):
client = FakeLLMClient({"classify": '{"doc_type":"contract"}'})
assert "contract" in client.complete("classify this document")
def test_default_fallback(self):
client = FakeLLMClient({"default": '{"ok":true}'})
assert client.complete("unknown prompt") == '{"ok":true}'
def test_call_history(self):
client = FakeLLMClient({"a": "1", "b": "2"})
client.complete("a")
client.complete("b")
assert len(client.calls) == 2
assert client.calls[0] == "a"
class TestMemRepository:
def test_insert_and_retrieve(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "application/vnd...", "base64data", batch_id="batch-1")
assert doc["filename"] == "test.docx"
assert doc["classify_status"] == "pending"
def test_list_pending(self, mem_repo):
mem_repo.insert_document("a.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("b.docx", "mime", "data", batch_id="b1")
mem_repo.insert_document("c.docx", "mime", "data", batch_id="b2")
pending = mem_repo.list_pending("b1")
assert len(pending) == 2
def test_set_classification(self, mem_repo):
doc = mem_repo.insert_document("test.docx", "mime", "data", batch_id="b1")
mem_repo.set_classification(doc["id"], "contract", "03700", None, "2026-02-01", "XXX")
updated = mem_repo.documents[doc["id"]]
assert updated["doc_type"] == "contract"
assert updated["classify_status"] == "classified"
def test_set_garbage(self, mem_repo):
doc = mem_repo.insert_document("счет.docx", "mime", "data", batch_id="b1")
mem_repo.set_classify_garbage(doc["id"], "filename_regex")
assert mem_repo.documents[doc["id"]]["doc_type"] == "garbage"
def test_contract_lifecycle(self, mem_repo):
cid = mem_repo.insert_contract("03700_1", "ЗАО XXX")
assert cid
doc = mem_repo.insert_document("test.docx", "mime", "data")
mem_repo.insert_supplement(cid, doc["id"], "initial")
supps = mem_repo.list_supplements(cid)
assert len(supps) == 1
assert supps[0]["type"] == "initial"