test: удалены устаревшие тесты deploy/tests (ссылались на старую compare/-архитектуру)
This commit is contained in:
@@ -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
@@ -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,'>').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();
|
|
||||||
@@ -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!"
|
|
||||||
@@ -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)
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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")
|
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user