translation-brief.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. /** Regression tests for the minimal-update briefing assembly. */
  2. import { describe, expect, it } from 'vitest'
  3. import {
  4. changedSpanIndices,
  5. computeMechanicalUpdate,
  6. firstOccurrenceContext,
  7. markdownUnits,
  8. parseTerminologyRows,
  9. relevantTerminologyRows,
  10. renderTranslationBrief,
  11. sectionSpans,
  12. spansAligned,
  13. termOffsets,
  14. } from './translation-brief.ts'
  15. const DOC = [
  16. 'Preamble line.',
  17. '',
  18. '# Title',
  19. '',
  20. 'Intro paragraph.',
  21. '',
  22. '## First',
  23. '',
  24. 'First body.',
  25. '',
  26. '```ts',
  27. 'const value = 1',
  28. '```',
  29. '',
  30. '## Second',
  31. '',
  32. '| A | B |',
  33. '|---|---|',
  34. '| 1 | 2 |',
  35. '',
  36. '- item one',
  37. '- item two',
  38. ].join('\n')
  39. describe('markdown spans', () => {
  40. it('lists units with container-scoped kinds in document order', () => {
  41. const kinds = markdownUnits(DOC).map(span => span.kind)
  42. expect(kinds).toEqual([
  43. 'root.0:paragraph',
  44. 'root.1:heading:1',
  45. 'root.2:paragraph',
  46. 'root.3:heading:2',
  47. 'root.4:paragraph',
  48. 'root.5:code',
  49. 'root.6:heading:2',
  50. 'root.7.0:tableRow',
  51. 'root.7.1:tableRow',
  52. 'root.8.0:listItem',
  53. 'root.8.1:listItem',
  54. ])
  55. })
  56. it('lists heading sections with a preamble span and heading labels', () => {
  57. const sections = sectionSpans(DOC)
  58. expect(sections.map(span => span.label)).toEqual([
  59. '(preamble before the first heading)',
  60. 'Title',
  61. 'First',
  62. 'Second',
  63. ])
  64. expect(sections[0]).toMatchObject({ startLine: 1, endLine: 2 })
  65. expect(sections[2]).toMatchObject({ startLine: 7, endLine: 14 })
  66. })
  67. it('labels units by their node type', () => {
  68. const units = markdownUnits(DOC)
  69. expect(units[0]!.label).toBe('paragraph')
  70. expect(units[1]!.label).toBe('heading')
  71. expect(units[7]!.label).toBe('tableRow')
  72. })
  73. it('aligns sections by depth only, so translated heading text still maps', () => {
  74. const zh = DOC.replace('## First', '## 第一节').replace('## Second', '## 第二节').replace('# Title', '# 标题')
  75. expect(spansAligned(sectionSpans(DOC), sectionSpans(zh))).toBe(true)
  76. })
  77. it('aligns span lists only on equal non-empty kind sequences', () => {
  78. const zh = DOC.replace('First body.', '第一段。').replace('item one', '第一项').replace('Intro paragraph.', '导语。')
  79. expect(spansAligned(markdownUnits(DOC), markdownUnits(zh))).toBe(true)
  80. const reshaped = DOC.replace('- item one\n- item two', 'merged paragraph')
  81. expect(spansAligned(markdownUnits(DOC), markdownUnits(reshaped))).toBe(false)
  82. expect(spansAligned([], [])).toBe(false)
  83. })
  84. it('reports the indices whose text changed', () => {
  85. const edited = DOC.replace('First body.', 'First body, revised.').replace('| 1 | 2 |', '| 1 | 3 |')
  86. expect(changedSpanIndices(markdownUnits(DOC), markdownUnits(edited))).toEqual([4, 8])
  87. })
  88. })
  89. describe('mechanical code updates', () => {
  90. const en = '# T\n\nProse.\n\n```sh\nrun one\n```\n'
  91. const zh = '# T\n\n中文。\n\n```sh\nrun one\n```\n'
  92. it('splices a fence-only edit into the counterpart', () => {
  93. const edited = en.replace('run one', 'run two')
  94. expect(computeMechanicalUpdate(en, edited, zh)).toBe(zh.replace('run one', 'run two'))
  95. })
  96. it('refuses when prose changed too', () => {
  97. const edited = en.replace('Prose.', 'Prose!').replace('run one', 'run two')
  98. expect(computeMechanicalUpdate(en, edited, zh)).toBeUndefined()
  99. })
  100. it('refuses when the counterpart fences already diverge from last-confirmed', () => {
  101. const edited = en.replace('run one', 'run two')
  102. expect(computeMechanicalUpdate(en, edited, zh.replace('run one', 'run stale'))).toBeUndefined()
  103. })
  104. it('refuses when fence counts differ or nothing changed', () => {
  105. expect(computeMechanicalUpdate(en, `${en}\n\`\`\`sh\nextra\n\`\`\`\n`, zh)).toBeUndefined()
  106. expect(computeMechanicalUpdate(en, en, zh)).toBeUndefined()
  107. })
  108. })
  109. const TERMINOLOGY = [
  110. '| English | 中文 | 首次出现 | 不要译作 | 备注 |',
  111. '|---|---|---|---|---|',
  112. '| agent | agent | agent(智能体) | 智能体 | |',
  113. '| session log | 会话日志 | | 会话记录 | |',
  114. '| gate | 门禁 | | | |',
  115. '| registry | 注册表 | | | |',
  116. ].join('\n')
  117. describe('terminology', () => {
  118. it('parses data rows and skips the header and separator', () => {
  119. const rows = parseTerminologyRows(TERMINOLOGY)
  120. expect(rows.map(row => row.english)).toEqual(['agent', 'session log', 'gate', 'registry'])
  121. expect(rows[0]).toMatchObject({ chinese: 'agent', first: 'agent(智能体)' })
  122. })
  123. it('matches English terms on word boundaries with plural inflections', () => {
  124. expect(termOffsets('two agents met', 'agent', true)).toEqual([4])
  125. expect(termOffsets('two registries', 'registry', true)).toEqual([4])
  126. expect(termOffsets('reagents', 'agent', true)).toEqual([])
  127. expect(termOffsets('', 'agent', true)).toEqual([])
  128. })
  129. it('selects rows for the changed text per direction', () => {
  130. expect(relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'All agents write a session log.').map(row => row.english))
  131. .toEqual(['agent', 'session log'])
  132. expect(relevantTerminologyRows(TERMINOLOGY, 'zh-to-en', '门禁在提交时运行。').map(row => row.english))
  133. .toEqual(['gate'])
  134. expect(relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'delegate the work')).toEqual([])
  135. })
  136. })
  137. describe('first-occurrence tracking', () => {
  138. const before = '# T\n\nAlpha paragraph.\n\nThe agent runs.\n'
  139. const after = '# T\n\nAlpha paragraph with an agent.\n\nThe agent runs.\n'
  140. const rows = parseTerminologyRows(TERMINOLOGY).filter(row => row.english === 'agent')
  141. it('flags a moved first occurrence and pulls the vacated span in', () => {
  142. const context = firstOccurrenceContext(
  143. before, after, markdownUnits(before), markdownUnits(after), rows, new Set([1]),
  144. )
  145. expect(context.notes).toHaveLength(1)
  146. expect(context.notes[0]).toContain('moved from #2 to #1')
  147. expect(context.extraSpanIndices).toEqual([2])
  148. })
  149. it('stays silent when the first occurrence does not move', () => {
  150. const unmoved = before.replace('Alpha paragraph.', 'Alpha paragraph, revised.')
  151. const context = firstOccurrenceContext(
  152. before, unmoved, markdownUnits(before), markdownUnits(unmoved), rows, new Set([1]),
  153. )
  154. expect(context.notes).toEqual([])
  155. expect(context.extraSpanIndices).toEqual([])
  156. })
  157. it('ignores rows without a first-occurrence rendering', () => {
  158. const bare = parseTerminologyRows(TERMINOLOGY).filter(row => row.english === 'gate')
  159. const withGate = after.replace('The agent runs.', 'The gate runs.')
  160. const context = firstOccurrenceContext(
  161. before, withGate, markdownUnits(before), markdownUnits(withGate), bare, new Set([2]),
  162. )
  163. expect(context.notes).toEqual([])
  164. })
  165. })
  166. describe('brief rendering', () => {
  167. const base = {
  168. sourcePath: 'docs/foo.md',
  169. counterpartPath: 'docs/foo.zh.md',
  170. direction: 'en-to-zh' as const,
  171. diff: '@@ -5 +5 @@\n-old text about the agent\n+new text about the agent',
  172. terminology: relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'the agent'),
  173. }
  174. const bundle = {
  175. index: 4,
  176. label: 'paragraph',
  177. confirmedSourceText: 'old text about the agent\n',
  178. currentSourceText: 'new text about the agent\n',
  179. counterpartText: '关于 agent 的旧文本\n',
  180. counterpartStartLine: 9,
  181. }
  182. it('renders unit bundles with three-way context and line anchors', () => {
  183. const brief = renderTranslationBrief({
  184. ...base,
  185. scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: ['agent: the document-wide first occurrence moved from #2 to #1; the agent(智能体) form moves with it (later occurrences drop the annotation).'] },
  186. })
  187. expect(brief).toContain('# Translation update briefing: docs/foo.md')
  188. expect(brief).toContain('## Changed units')
  189. expect(brief).toContain('### #4 paragraph — counterpart at docs/foo.zh.md:9')
  190. expect(brief).toContain('Last-confirmed English:')
  191. expect(brief).toContain('Current Chinese (bring this along):')
  192. expect(brief).toContain('## First-occurrence notes')
  193. expect(brief).toContain('agent(智能体)')
  194. expect(brief).toContain('首次出现 annotations attach to the document-wide first occurrence only')
  195. expect(brief).toContain('targets in the active bilingual corpus use `.zh.md` for Chinese')
  196. expect(brief).toContain('a missing in-scope counterpart is an error')
  197. expect(brief).toContain('verify-translation-pairing --write docs/foo.md')
  198. })
  199. it('marks first-occurrence bundles and omits their unchanged confirmed text', () => {
  200. const brief = renderTranslationBrief({
  201. ...base,
  202. scope: {
  203. kind: 'units',
  204. bundles: [{ ...bundle, reason: 'first-occurrence', confirmedSourceText: bundle.currentSourceText }],
  205. firstOccurrenceNotes: [],
  206. },
  207. })
  208. expect(brief).toContain('unchanged; included for a first-occurrence move')
  209. expect(brief).not.toContain('Last-confirmed English:')
  210. })
  211. it('renders the mechanical scope with the --apply command', () => {
  212. const brief = renderTranslationBrief({ ...base, scope: { kind: 'mechanical' } })
  213. expect(brief).toContain('## Mechanical update — no translation judgment involved')
  214. expect(brief).toContain('gen-translation-brief --apply docs/foo.md')
  215. expect(brief).not.toContain('## Changed units')
  216. })
  217. it('renders the section fallback under its own heading', () => {
  218. const brief = renderTranslationBrief({
  219. ...base,
  220. scope: { kind: 'sections', bundles: [bundle], firstOccurrenceNotes: [] },
  221. })
  222. expect(brief).toContain('## Changed sections')
  223. expect(brief).toContain('fine-grained units do not align')
  224. })
  225. it('renders the document fallback with its reason and no bundles', () => {
  226. const brief = renderTranslationBrief({
  227. ...base,
  228. scope: { kind: 'document', reason: 'BOTH sides changed since the pair was last confirmed consistent, so no side is a trustworthy mapping anchor; decide which side owns each divergence.' },
  229. })
  230. expect(brief).toContain('## Whole-document update required')
  231. expect(brief).toContain('BOTH sides changed')
  232. expect(brief).toContain('locate the affected regions yourself')
  233. })
  234. it('renders the English-target digest for zh-to-en updates', () => {
  235. const brief = renderTranslationBrief({
  236. ...base,
  237. direction: 'zh-to-en',
  238. sourcePath: 'docs/foo.zh.md',
  239. counterpartPath: 'docs/foo.md',
  240. scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: [] },
  241. })
  242. expect(brief).toContain('exactly what the new Chinese states')
  243. expect(brief).toContain('targets in the active bilingual corpus use `.md` for English')
  244. expect(brief).toContain('targets outside the corpus keep the authored path')
  245. expect(brief).toContain('verify-translation-pairing --write docs/foo.md')
  246. })
  247. it('grows bundle fences past tilde runs in the text', () => {
  248. const brief = renderTranslationBrief({
  249. ...base,
  250. scope: {
  251. kind: 'units',
  252. bundles: [{ ...bundle, counterpartText: '~~~~\ninner\n~~~~\n' }],
  253. firstOccurrenceNotes: [],
  254. },
  255. })
  256. expect(brief).toContain('~~~~~markdown')
  257. })
  258. })