114 lines
5.1 KiB
TypeScript
114 lines
5.1 KiB
TypeScript
/**
|
|
* Unit tests for the result-time contextual-diff computation (`src/diff.ts`):
|
|
* the pure before/after → {@link FileDiff}[] hunk builder and the defensive
|
|
* `meta` narrowing. These pin the exact hunk reconstruction (context lines,
|
|
* multi-hunk replaceAll, pure insertion/deletion, no-op) that UIs render.
|
|
*/
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '../src/diff.ts'
|
|
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
|
|
|
const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n'
|
|
|
|
describe('computeHunkDiffs', () => {
|
|
it('a single-line change yields one hunk with ±context lines on both sides', () => {
|
|
const before = lines(8)
|
|
const after = before.replace('line4', 'CHANGED')
|
|
const diffs = computeHunkDiffs('f.txt', before, after)
|
|
expect(diffs).toEqual([{
|
|
path: 'f.txt',
|
|
oldText: 'line1\nline2\nline3\nline4\nline5\nline6\nline7',
|
|
newText: 'line1\nline2\nline3\nCHANGED\nline5\nline6\nline7',
|
|
}])
|
|
})
|
|
|
|
it('a scattered replace_all yields one FileDiff PER hunk (matching per-site editor blocks)', () => {
|
|
const before = lines(20)
|
|
const after = before.replace('line3', 'A').replace('line16', 'B')
|
|
const diffs = computeHunkDiffs('f.txt', before, after)
|
|
expect(diffs).toHaveLength(2)
|
|
expect(diffs[0]?.path).toBe('f.txt')
|
|
expect(diffs[0]?.oldText).toContain('line3')
|
|
expect(diffs[0]?.newText).toContain('A')
|
|
expect(diffs[1]?.oldText).toContain('line16')
|
|
expect(diffs[1]?.newText).toContain('B')
|
|
// The two hunks are distinct sites, not one merged block.
|
|
expect(diffs[0]?.newText).not.toContain('B')
|
|
expect(diffs[1]?.newText).not.toContain('A')
|
|
})
|
|
|
|
it('identical before/after (a no-op) yields no hunks', () => {
|
|
expect(computeHunkDiffs('f.txt', 'same\n', 'same\n')).toEqual([])
|
|
})
|
|
|
|
it('a pure insertion into empty content reports oldText null (nothing to diff against)', () => {
|
|
const diffs = computeHunkDiffs('f.txt', '', 'brand new\n')
|
|
expect(diffs).toEqual([{ path: 'f.txt', oldText: null, newText: 'brand new' }])
|
|
})
|
|
|
|
it('a pure deletion of the whole file reports newText empty', () => {
|
|
const diffs = computeHunkDiffs('f.txt', 'gone\n', '')
|
|
expect(diffs).toEqual([{ path: 'f.txt', oldText: 'gone', newText: '' }])
|
|
})
|
|
|
|
it('drops the "\\ No newline at end of file" marker from a no-trailing-newline change', () => {
|
|
const diffs = computeHunkDiffs('f.txt', 'x', 'y')
|
|
// The marker line (starting with "\\") must never leak into a diff block.
|
|
expect(diffs).toEqual([{ path: 'f.txt', oldText: 'x', newText: 'y' }])
|
|
expect(diffs[0]?.oldText).not.toContain('\\')
|
|
expect(diffs[0]?.newText).not.toContain('\\')
|
|
})
|
|
|
|
it('uses DIFF_CONTEXT (3) surrounding lines', () => {
|
|
expect(DIFF_CONTEXT).toBe(3)
|
|
const before = lines(20)
|
|
const after = before.replace('line10', 'CHANGED')
|
|
const [diff] = computeHunkDiffs('f.txt', before, after)
|
|
// 3 context above (7,8,9) + the change + 3 below (11,12,13) = 7 lines a side.
|
|
expect(diff?.oldText?.split('\n')).toHaveLength(7)
|
|
expect(diff?.newText.split('\n')).toHaveLength(7)
|
|
expect(diff?.oldText?.split('\n')[0]).toBe('line7')
|
|
})
|
|
})
|
|
|
|
describe('diffsFromMeta (defensive narrowing)', () => {
|
|
// The narrowing accepts an opaque JsonValue; a malformed payload is not a
|
|
// statically-valid JsonValue, so route every case through one cast helper that
|
|
// mirrors how a hand-edited/older session log delivers arbitrary shapes.
|
|
const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
|
|
const good = { diffs: [{ path: 'f.txt', oldText: 'a', newText: 'b' }] }
|
|
|
|
it('narrows a well-formed { diffs } payload', () => {
|
|
expect(diffsFromMeta(m(good))).toEqual(good.diffs)
|
|
})
|
|
|
|
it('accepts a diff whose oldText is null (a create-style hunk)', () => {
|
|
const meta = { diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }
|
|
expect(diffsFromMeta(m(meta))).toEqual(meta.diffs)
|
|
})
|
|
|
|
it('rejects undefined / non-object / array meta', () => {
|
|
expect(diffsFromMeta(undefined)).toBeUndefined()
|
|
expect(diffsFromMeta(null)).toBeUndefined()
|
|
expect(diffsFromMeta(m('nope'))).toBeUndefined()
|
|
expect(diffsFromMeta(m([]))).toBeUndefined()
|
|
})
|
|
|
|
it('rejects a missing / empty / non-array diffs field', () => {
|
|
expect(diffsFromMeta(m({}))).toBeUndefined()
|
|
expect(diffsFromMeta(m({ diffs: [] }))).toBeUndefined()
|
|
expect(diffsFromMeta(m({ diffs: 'x' }))).toBeUndefined()
|
|
})
|
|
|
|
it('rejects a diffs array containing a malformed entry', () => {
|
|
expect(diffsFromMeta(m({ diffs: [{ path: 'f.txt', oldText: 'a' }] }))).toBeUndefined()
|
|
expect(diffsFromMeta(m({ diffs: [{ path: 1, oldText: 'a', newText: 'b' }] }))).toBeUndefined()
|
|
expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 5, newText: 'b' }] }))).toBeUndefined()
|
|
expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 'a', newText: 7 }] }))).toBeUndefined()
|
|
expect(diffsFromMeta(m({ diffs: [null] }))).toBeUndefined()
|
|
expect(diffsFromMeta(m({ diffs: ['x'] }))).toBeUndefined()
|
|
expect(diffsFromMeta(m({ diffs: [[]] }))).toBeUndefined()
|
|
})
|
|
})
|