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('