diff.spec.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. /**
  2. * Unit tests for the result-time contextual-diff computation (`src/diff.ts`):
  3. * the pure before/after → {@link FileDiff}[] hunk builder and the defensive
  4. * `meta` narrowing. These pin the exact hunk reconstruction (context lines,
  5. * multi-hunk replaceAll, pure insertion/deletion, no-op) that UIs render.
  6. */
  7. import { describe, expect, it } from 'vitest'
  8. import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '../src/diff.ts'
  9. import type { JsonValue } from '@deepseek-ai/dsh-util-values'
  10. const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n'
  11. describe('computeHunkDiffs', () => {
  12. it('a single-line change yields one hunk with ±context lines on both sides', () => {
  13. const before = lines(8)
  14. const after = before.replace('line4', 'CHANGED')
  15. const diffs = computeHunkDiffs('f.txt', before, after)
  16. expect(diffs).toEqual([{
  17. path: 'f.txt',
  18. oldText: 'line1\nline2\nline3\nline4\nline5\nline6\nline7',
  19. newText: 'line1\nline2\nline3\nCHANGED\nline5\nline6\nline7',
  20. }])
  21. })
  22. it('a scattered replace_all yields one FileDiff PER hunk (matching per-site editor blocks)', () => {
  23. const before = lines(20)
  24. const after = before.replace('line3', 'A').replace('line16', 'B')
  25. const diffs = computeHunkDiffs('f.txt', before, after)
  26. expect(diffs).toHaveLength(2)
  27. expect(diffs[0]?.path).toBe('f.txt')
  28. expect(diffs[0]?.oldText).toContain('line3')
  29. expect(diffs[0]?.newText).toContain('A')
  30. expect(diffs[1]?.oldText).toContain('line16')
  31. expect(diffs[1]?.newText).toContain('B')
  32. // The two hunks are distinct sites, not one merged block.
  33. expect(diffs[0]?.newText).not.toContain('B')
  34. expect(diffs[1]?.newText).not.toContain('A')
  35. })
  36. it('identical before/after (a no-op) yields no hunks', () => {
  37. expect(computeHunkDiffs('f.txt', 'same\n', 'same\n')).toEqual([])
  38. })
  39. it('a pure insertion into empty content reports oldText null (nothing to diff against)', () => {
  40. const diffs = computeHunkDiffs('f.txt', '', 'brand new\n')
  41. expect(diffs).toEqual([{ path: 'f.txt', oldText: null, newText: 'brand new' }])
  42. })
  43. it('a pure deletion of the whole file reports newText empty', () => {
  44. const diffs = computeHunkDiffs('f.txt', 'gone\n', '')
  45. expect(diffs).toEqual([{ path: 'f.txt', oldText: 'gone', newText: '' }])
  46. })
  47. it('drops the "\\ No newline at end of file" marker from a no-trailing-newline change', () => {
  48. const diffs = computeHunkDiffs('f.txt', 'x', 'y')
  49. // The marker line (starting with "\\") must never leak into a diff block.
  50. expect(diffs).toEqual([{ path: 'f.txt', oldText: 'x', newText: 'y' }])
  51. expect(diffs[0]?.oldText).not.toContain('\\')
  52. expect(diffs[0]?.newText).not.toContain('\\')
  53. })
  54. it('uses DIFF_CONTEXT (3) surrounding lines', () => {
  55. expect(DIFF_CONTEXT).toBe(3)
  56. const before = lines(20)
  57. const after = before.replace('line10', 'CHANGED')
  58. const [diff] = computeHunkDiffs('f.txt', before, after)
  59. // 3 context above (7,8,9) + the change + 3 below (11,12,13) = 7 lines a side.
  60. expect(diff?.oldText?.split('\n')).toHaveLength(7)
  61. expect(diff?.newText.split('\n')).toHaveLength(7)
  62. expect(diff?.oldText?.split('\n')[0]).toBe('line7')
  63. })
  64. })
  65. describe('diffsFromMeta (defensive narrowing)', () => {
  66. // The narrowing accepts an opaque JsonValue; a malformed payload is not a
  67. // statically-valid JsonValue, so route every case through one cast helper that
  68. // mirrors how a hand-edited/older session log delivers arbitrary shapes.
  69. const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
  70. const good = { diffs: [{ path: 'f.txt', oldText: 'a', newText: 'b' }] }
  71. it('narrows a well-formed { diffs } payload', () => {
  72. expect(diffsFromMeta(m(good))).toEqual(good.diffs)
  73. })
  74. it('accepts a diff whose oldText is null (a create-style hunk)', () => {
  75. const meta = { diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }
  76. expect(diffsFromMeta(m(meta))).toEqual(meta.diffs)
  77. })
  78. it('rejects undefined / non-object / array meta', () => {
  79. expect(diffsFromMeta(undefined)).toBeUndefined()
  80. expect(diffsFromMeta(null)).toBeUndefined()
  81. expect(diffsFromMeta(m('nope'))).toBeUndefined()
  82. expect(diffsFromMeta(m([]))).toBeUndefined()
  83. })
  84. it('rejects a missing / empty / non-array diffs field', () => {
  85. expect(diffsFromMeta(m({}))).toBeUndefined()
  86. expect(diffsFromMeta(m({ diffs: [] }))).toBeUndefined()
  87. expect(diffsFromMeta(m({ diffs: 'x' }))).toBeUndefined()
  88. })
  89. it('rejects a diffs array containing a malformed entry', () => {
  90. expect(diffsFromMeta(m({ diffs: [{ path: 'f.txt', oldText: 'a' }] }))).toBeUndefined()
  91. expect(diffsFromMeta(m({ diffs: [{ path: 1, oldText: 'a', newText: 'b' }] }))).toBeUndefined()
  92. expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 5, newText: 'b' }] }))).toBeUndefined()
  93. expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 'a', newText: 7 }] }))).toBeUndefined()
  94. expect(diffsFromMeta(m({ diffs: [null] }))).toBeUndefined()
  95. expect(diffsFromMeta(m({ diffs: ['x'] }))).toBeUndefined()
  96. expect(diffsFromMeta(m({ diffs: [[]] }))).toBeUndefined()
  97. })
  98. })