При сохранении записи журнала (PUT /api/entries/:id) сравнивается состояние до и после, и в audit_log пишется не только факт правки, но и сами изменения: пословный дифф текста, статистика добавленных и удалённых слов, а также смена ФИО, группы и темы модуля. - diff.js: пословный LCS-дифф без зависимостей, обрезка больших текстов, сборка изменений по полям записи, облегчённый target для списка аудита - source правки: manual / ai / ai_manual / ai_revert; журнал шлёт edit_source, сервер доверяет явному значению и определяет источник по description_ai как запасной вариант - те же диффы пишутся для автопроверки ИИ (entry.ai.auto-check) и отката к оригиналу (entry.ai.revert) - GET /api/audit отдаёт список без diff, GET /api/audit/:id — полный target, чтобы не грузить килобайты текста на каждую строку - Аудит: колонка «Кто», сводка в таблице, модалка с подсветкой удалённого и добавленного текста, «было/стало» для полей - auth.login теперь пишет user_id, иначе колонка «Кто» показывала «система» - diff.selftest.js: 16 тестов диффа; README и AGENTS обновлены
216 lines
10 KiB
JavaScript
216 lines
10 KiB
JavaScript
const assert = require('assert');
|
|
const { textDiff, buildEntryDiff, normalizeEditSource, stripDiffs } = require('./diff');
|
|
|
|
function reconstruct(segments, types) {
|
|
return segments.filter(s => types.includes(s.type)).map(s => s.text).join('');
|
|
}
|
|
|
|
function baseEntry(over = {}) {
|
|
return {
|
|
student_name: 'Иванов Иван',
|
|
group_id: 1,
|
|
group_name: 'Первый класс',
|
|
module_id: 5,
|
|
module_name: 'Модуль 1',
|
|
description: 'Я сделал проект по окружающему миру и сдал его вчера.',
|
|
description_ai: null,
|
|
...over
|
|
};
|
|
}
|
|
|
|
function testNoChange() {
|
|
const d = textDiff('одно и то же', 'одно и то же');
|
|
assert.strictEqual(d.changed, false, 'identical text is not changed');
|
|
assert.deepStrictEqual(d.segments, [], 'no segments for identical text');
|
|
assert.strictEqual(d.stats.added_chars, 0, 'no added chars');
|
|
assert.strictEqual(d.stats.removed_chars, 0, 'no removed chars');
|
|
}
|
|
|
|
function testReconstruct() {
|
|
const before = 'Он купил хлеб и молоко вчера';
|
|
const after = 'Она купила хлеб и молоко сегодня';
|
|
const d = textDiff(before, after);
|
|
assert.strictEqual(d.changed, true, 'text changed');
|
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), before, 'before is reconstructible');
|
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), after, 'after is reconstructible');
|
|
assert.ok(d.stats.removed_words >= 1, 'removed words counted');
|
|
assert.ok(d.stats.added_words >= 1, 'added words counted');
|
|
assert.ok(d.stats.chars_before === before.length, 'chars_before');
|
|
assert.ok(d.stats.chars_after === after.length, 'chars_after');
|
|
}
|
|
|
|
function testReconstructInsertOnly() {
|
|
const d = textDiff('сделал проект', 'сделал большой проект');
|
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал проект', 'before is reconstructible');
|
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал большой проект', 'after is reconstructible');
|
|
}
|
|
|
|
function testReconstructDeleteOnly() {
|
|
const d = textDiff('сделал большой проект', 'сделал проект');
|
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'del']), 'сделал большой проект', 'before is reconstructible');
|
|
assert.strictEqual(reconstruct(d.segments, ['eq', 'add']), 'сделал проект', 'after is reconstructible');
|
|
}
|
|
|
|
function testInsertOnly() {
|
|
const d = textDiff('сделал проект', 'сделал большой проект');
|
|
assert.strictEqual(d.stats.removed_chars, 0, 'insert removes nothing');
|
|
assert.ok(d.stats.added_words === 1, 'one word added');
|
|
assert.ok(reconstruct(d.segments, 'add').includes('большой'), 'added word visible');
|
|
}
|
|
|
|
function testDeleteOnly() {
|
|
const d = textDiff('сделал большой проект', 'сделал проект');
|
|
assert.strictEqual(d.stats.added_chars, 0, 'delete adds nothing');
|
|
assert.ok(d.stats.removed_words === 1, 'one word removed');
|
|
assert.ok(reconstruct(d.segments, 'del').includes('большой'), 'removed word visible');
|
|
}
|
|
|
|
function testEmptyToText() {
|
|
const d = textDiff(null, 'новый текст');
|
|
assert.strictEqual(d.changed, true, 'null to text is a change');
|
|
assert.strictEqual(reconstruct(d.segments, 'add'), 'новый текст', 'whole text added');
|
|
assert.strictEqual(d.stats.added_words, 2, 'both words added');
|
|
}
|
|
|
|
function testTextToEmpty() {
|
|
const d = textDiff('старый текст', '');
|
|
assert.strictEqual(d.changed, true, 'text to empty is a change');
|
|
assert.strictEqual(reconstruct(d.segments, 'del'), 'старый текст', 'whole text removed');
|
|
assert.strictEqual(d.stats.removed_words, 2, 'both words removed');
|
|
}
|
|
|
|
function testMultiline() {
|
|
const before = 'строка один\nстрока два\nстрока три';
|
|
const after = 'строка один\nстрока ДВА\nстрока три';
|
|
const d = textDiff(before, after);
|
|
assert.strictEqual(d.changed, true, 'multiline changed');
|
|
assert.ok(reconstruct(d.segments, 'del').includes('два'), 'old word marked removed');
|
|
assert.ok(reconstruct(d.segments, 'add').includes('ДВА'), 'new word marked added');
|
|
}
|
|
|
|
function testLargeTextFallback() {
|
|
const before = Array.from({ length: 4000 }, (_, i) => `слово${i}`).join(' ');
|
|
const after = before.replace('слово2000 ', 'слово2000И ');
|
|
const started = Date.now();
|
|
const d = textDiff(before, after);
|
|
const elapsed = Date.now() - started;
|
|
assert.strictEqual(d.changed, true, 'large text changed');
|
|
assert.ok(d.stats.removed_words >= 1 && d.stats.added_words >= 1, 'large diff still counts words');
|
|
assert.ok(d.segments.length > 0, 'large diff still has segments');
|
|
assert.ok(reconstruct(d.segments, ['eq', 'del']).length > 0, 'large diff keeps removed text');
|
|
assert.ok(reconstruct(d.segments, ['eq', 'add']).length > 0, 'large diff keeps added text');
|
|
assert.ok(elapsed < 2000, `large text diff is fast (${elapsed}ms)`);
|
|
}
|
|
|
|
function testCapping() {
|
|
const before = 'a '.repeat(6000);
|
|
const after = 'b '.repeat(6000);
|
|
const d = textDiff(before, after);
|
|
assert.strictEqual(d.truncated, true, 'huge diff is truncated');
|
|
const total = d.segments.reduce((n, s) => n + s.text.length, 0);
|
|
assert.ok(total <= 6200, `diff payload is capped (${total} chars)`);
|
|
assert.ok(d.stats.removed_words > 100, 'stats are computed on the full text');
|
|
}
|
|
|
|
function testEntryDiffDescription() {
|
|
const before = baseEntry();
|
|
const after = baseEntry({ description: 'Я сделал проект по окружающему миру и сдал его сегодня.' });
|
|
const changes = buildEntryDiff(before, after);
|
|
assert.strictEqual(changes.length, 1, 'only description changed');
|
|
assert.strictEqual(changes[0].field, 'description', 'field is description');
|
|
assert.strictEqual(changes[0].label, 'Текст работы', 'label is human readable');
|
|
assert.ok(changes[0].diff.length > 0, 'diff segments present');
|
|
assert.strictEqual(changes[0].stats.removed_words, 1, 'one word removed');
|
|
assert.strictEqual(changes[0].stats.added_words, 1, 'one word added');
|
|
}
|
|
|
|
function testEntryDiffFields() {
|
|
const before = baseEntry();
|
|
const after = baseEntry({
|
|
student_name: 'Петров Пётр',
|
|
group_id: 2,
|
|
group_name: 'Второй класс',
|
|
module_id: null,
|
|
module_name: null
|
|
});
|
|
const changes = buildEntryDiff(before, after);
|
|
const fields = changes.map(c => c.field).sort();
|
|
assert.deepStrictEqual(fields, ['group_id', 'module_id', 'student_name'], 'three fields changed');
|
|
const group = changes.find(c => c.field === 'group_id');
|
|
assert.strictEqual(group.before, '1 · Первый класс', 'group before with name');
|
|
assert.strictEqual(group.after, '2 · Второй класс', 'group after with name');
|
|
const mod = changes.find(c => c.field === 'module_id');
|
|
assert.ok(!mod.after, 'module cleared -> null');
|
|
assert.strictEqual(mod.before, '5 · Модуль 1', 'module before with name');
|
|
}
|
|
|
|
function testEntryDiffNoChange() {
|
|
const before = baseEntry();
|
|
const changes = buildEntryDiff(before, baseEntry());
|
|
assert.deepStrictEqual(changes, [], 'no changes detected');
|
|
}
|
|
|
|
function testEditSource() {
|
|
const before = baseEntry();
|
|
const afterAi = baseEntry({ description: 'Текст от ИИ', description_ai: 'Текст от ИИ' });
|
|
assert.strictEqual(normalizeEditSource('ai', before, afterAi), 'ai', 'ai source');
|
|
assert.strictEqual(normalizeEditSource('', before, afterAi), 'ai', 'ai detected from description_ai');
|
|
assert.strictEqual(normalizeEditSource('ai', before, baseEntry({ description: 'Текст руками', description_ai: 'Текст от ИИ' })), 'ai', 'explicit ai claim is trusted');
|
|
assert.strictEqual(normalizeEditSource('ai_manual', before, afterAi), 'ai_manual', 'ai_manual kept');
|
|
assert.strictEqual(normalizeEditSource('', before, baseEntry({ description: 'Текст руками' })), 'manual', 'manual by default');
|
|
assert.strictEqual(normalizeEditSource('ai', baseEntry(), baseEntry({ group_id: 2 })), 'manual', 'no text change -> manual');
|
|
assert.strictEqual(normalizeEditSource('<script>', before, baseEntry({ description: 'x' })), 'manual', 'garbage source ignored');
|
|
}
|
|
|
|
function testStripDiffs() {
|
|
const before = baseEntry();
|
|
const after = baseEntry({ description: 'Другой текст целиком' });
|
|
const target = { id: 1, source: 'manual', changes: buildEntryDiff(before, { ...after, group_id: 2, group_name: 'Два' }) };
|
|
const light = stripDiffs(target);
|
|
assert.strictEqual(light.changes.length, 2, 'changes preserved');
|
|
const desc = light.changes.find(c => c.field === 'description');
|
|
assert.ok(desc, 'description change kept');
|
|
assert.strictEqual(desc.diff, undefined, 'diff dropped in list payload');
|
|
assert.ok(desc.stats, 'stats preserved');
|
|
assert.ok(target.changes.find(c => c.field === 'description').diff.length > 0, 'original target still has diff');
|
|
const grp = light.changes.find(c => c.field === 'group_id');
|
|
assert.ok(grp && 'before' in grp && 'after' in grp, 'simple field before/after kept in list payload');
|
|
assert.strictEqual(grp.before, '1 · Первый класс', 'group before kept');
|
|
assert.strictEqual(grp.after, '2 · Два', 'group after kept');
|
|
assert.strictEqual(stripDiffs(null), null, 'null target');
|
|
assert.deepStrictEqual(stripDiffs({ a: 1 }), { a: 1 }, 'target without changes untouched');
|
|
assert.strictEqual(stripDiffs({ changes: [] }).changes.length, 0, 'empty changes kept');
|
|
}
|
|
|
|
const tests = [
|
|
testNoChange,
|
|
testReconstruct,
|
|
testReconstructInsertOnly,
|
|
testReconstructDeleteOnly,
|
|
testInsertOnly,
|
|
testDeleteOnly,
|
|
testEmptyToText,
|
|
testTextToEmpty,
|
|
testMultiline,
|
|
testLargeTextFallback,
|
|
testCapping,
|
|
testEntryDiffDescription,
|
|
testEntryDiffFields,
|
|
testEntryDiffNoChange,
|
|
testEditSource,
|
|
testStripDiffs
|
|
];
|
|
|
|
let failed = 0;
|
|
for (const t of tests) {
|
|
try {
|
|
t();
|
|
console.log('ok ', t.name);
|
|
} catch (e) {
|
|
failed++;
|
|
console.log('FAIL', t.name, '-', e.message);
|
|
}
|
|
}
|
|
console.log(failed ? `\n${failed} из ${tests.length} тестов упали` : `\nВсе ${tests.length} тестов пройдены`);
|
|
process.exit(failed ? 1 : 0);
|