Files
WhatIDo/diff.selftest.js
T
dev 54cf5bbfa4 feat(audit): показывать изменения текста записи по шагам
При сохранении записи журнала (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 обновлены
2026-09-27 11:29:17 +03:00

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);