translation-prompt.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. /** Unit tests for the prompt-v7 content and unchanged three-section protocol. */
  2. import { readFileSync } from 'node:fs'
  3. import { join, resolve } from 'node:path'
  4. import { describe, expect, it } from 'vitest'
  5. import {
  6. consumeTranslationResponse,
  7. parseTranslationResponse,
  8. renderTranslationPrompt,
  9. renderTranslationRequest,
  10. renderTranslationResponse,
  11. } from './translation-prompt.ts'
  12. const root = resolve(import.meta.dirname, '..')
  13. const document = readFileSync(join(root, 'docs/i18n/translation-prompt.md'), 'utf8')
  14. const terminology = '| English | 中文 |\n|---|---|\n| agent | agent |'
  15. const retainedExamples = [
  16. ['### Colloquial verb → Professional verb', 'The repo pins pnpm@11.7.0 in package.json', '该仓库在 package.json 中固定使用 pnpm@11.7.0'],
  17. ['### Run-on sentence → Natural phrasing with pause', 'Read docs/architecture.md before changing anything under packages/.', '在修改 packages/ 目录下的任何内容之前,请先阅读 docs/architecture.md。'],
  18. ['### Stiff passive voice → Active and natural', 'a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.', '门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。'],
  19. ['### Invented word → Natural expression', 'A sidecar record of both blob hashes makes consistency checkable', '伴随记录保存两侧 blob hash,使一致性可检查'],
  20. ['### Em-dash → Colon/period', 'FIXME — an issue that should block a new release.', 'FIXME:应当阻塞新版本发布的问题。'],
  21. ['### Overly literal → Meaningful rendering', 'awkward phrasing is easier to hear without the source anchoring you', '不对照原文时,更容易察觉别扭的表达'],
  22. ['### Terminology — do not translate what should be kept in English', 'typed service seams, and explicit extension points', '类型化的服务 seam 与显式扩展点'],
  23. ['### Slang/jargon → Professional phrasing', 'The committed agent workflow lives in .agents/skills/dsh-translate-docs', '仓库内置的 agent 工作流见 .agents/skills/dsh-translate-docs'],
  24. ['### "For humans" — translate the intent, not the word', 'For humans, start with the development guide', '面向开发者:请先阅读开发指南'],
  25. ['### Code block comments — NEVER translate', '# full-screen TUI coding agent (needs DEEPSEEK_API_KEY)', 'keep exactly as-is, byte-for-byte'],
  26. ['### Language switcher — flip direction', 'English | [中文](README.zh.md)', '[English](README.md) | 中文'],
  27. ]
  28. describe('translation prompt rendering', () => {
  29. it('renders both directions with every placeholder resolved', () => {
  30. const en = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })
  31. expect(en).toContain('from English to Chinese')
  32. expect(en).toContain(terminology)
  33. expect(en).not.toContain('{{')
  34. expect(en).toContain('plain source stays plain (必须)')
  35. expect(en).toContain('For an English target, use the established English technical term')
  36. expect(en).toContain('does a Chinese target use an established Chinese rendering')
  37. expect(en).toContain('does an English target use the established English technical term')
  38. expect(en).toContain('The parser removes exactly one framing escape')
  39. const zh = renderTranslationPrompt(document, { sourceLanguage: 'Chinese', sourceFilename: 'guide.zh.md', terminology })
  40. expect(zh).toContain('from Chinese to English')
  41. })
  42. it('retains every v4 embedded example', () => {
  43. for (const example of retainedExamples) {
  44. for (const fragment of example) expect(document).toContain(fragment)
  45. }
  46. })
  47. it('states the selected v7 safeguards', () => {
  48. const rendered = renderTranslationPrompt(document, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })
  49. expect(rendered).toContain('## Priority')
  50. expect(rendered).toContain('### Faithfulness')
  51. expect(rendered).toContain('do not invent a filename or switcher')
  52. expect(rendered).toContain('Markdown emphasis markers do not create a word boundary')
  53. expect(rendered).toContain('Never invent responsibility merely to avoid a passive construction')
  54. expect(rendered).toContain('Never vary a terminology-table form, defined concept, or contract verb merely for stylistic variety')
  55. expect(rendered).toContain('Return exactly three raw XML sections')
  56. })
  57. it('rejects a template with unknown or missing placeholders', () => {
  58. const alien = document.replaceAll('{{terminology}}', '{{terms_prompt}}')
  59. expect(() => renderTranslationPrompt(alien, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/unsupported placeholder/)
  60. const missing = document.replaceAll('{{terminology}}', '')
  61. expect(() => renderTranslationPrompt(missing, { sourceLanguage: 'English', sourceFilename: 'guide.md', terminology })).toThrow(/required placeholder/)
  62. })
  63. it('rejects unmatched placeholder delimiters', () => {
  64. for (const delimiter of ['{{', '}}']) {
  65. const malformed = document.replace('Your task is to translate', `Your task ${delimiter} is to translate`)
  66. expect(() => renderTranslationPrompt(malformed, {
  67. sourceLanguage: 'English',
  68. sourceFilename: 'guide.md',
  69. terminology,
  70. })).toThrow(/malformed placeholder syntax/)
  71. }
  72. })
  73. it('assembles bare few-shot turns before the real source document', () => {
  74. const request = renderTranslationRequest(document, {
  75. sourceLanguage: 'English',
  76. sourceFilename: 'guide.md',
  77. sourceDocument: '# Guide\n\nNew source.',
  78. terminology,
  79. examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
  80. })
  81. expect(request.targetFilename).toBe('guide.zh.md')
  82. expect(request.messages.map(message => message.role)).toEqual(['system', 'user', 'assistant', 'user'])
  83. expect(request.messages.slice(1).map(message => message.content)).toEqual([
  84. '# Example\n\nEnglish.',
  85. '# 示例\n\n中文。',
  86. '# Guide\n\nNew source.',
  87. ])
  88. const reverse = renderTranslationRequest(document, {
  89. sourceLanguage: 'Chinese',
  90. sourceFilename: 'guide.zh.md',
  91. sourceDocument: '# 指南\n\n新源文。',
  92. terminology,
  93. examples: [{ english: '# Example\n\nEnglish.', chinese: '# 示例\n\n中文。' }],
  94. })
  95. expect(reverse.targetFilename).toBe('guide.md')
  96. expect(reverse.messages.slice(1).map(message => message.content)).toEqual([
  97. '# 示例\n\n中文。',
  98. '# Example\n\nEnglish.',
  99. '# 指南\n\n新源文。',
  100. ])
  101. })
  102. })
  103. describe('translation response sections', () => {
  104. it('round-trips Markdown bodies', () => {
  105. const response = { translation: '# 标题\n\n正文 **加粗**。', review: '- [Tone] 修正一处。\n- 无修正', final: '# 标题\n\n定稿。' }
  106. expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response)
  107. })
  108. it('tolerates a fenced xml wrapper around the whole response', () => {
  109. const fenced = '```xml\n<translation>\nA\n</translation>\n\n<review>\n- 无修正\n</review>\n\n<final>\nA\n</final>\n```'
  110. expect(parseTranslationResponse(fenced).final).toBe('A')
  111. })
  112. it('keeps an inline close tag inside prose from terminating the section', () => {
  113. const doc = { translation: 'the wire format uses </translation> as its close tag', review: '- 无修正', final: 'F' }
  114. expect(parseTranslationResponse(renderTranslationResponse(doc))).toEqual(doc)
  115. })
  116. it('round-trips wrapper-tag lines inside Markdown bodies', () => {
  117. const doc = {
  118. translation: '```xml\n</translation>\n```',
  119. review: '- [Structure] Preserved `<final>` on its own line.',
  120. final: 'literal delimiters\n</final>\n\\</final>',
  121. }
  122. const rendered = renderTranslationResponse(doc)
  123. expect(parseTranslationResponse(rendered)).toEqual(doc)
  124. expect(() => parseTranslationResponse(rendered.replace('\\</translation>', '</translation>'))).toThrow(/duplicate <translation>/)
  125. })
  126. it('rejects a duplicate section appearing before final', () => {
  127. const early = '<translation>\nA\n</translation>\n<translation>\nB\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>'
  128. expect(() => parseTranslationResponse(early)).toThrow(/duplicate <translation>/)
  129. })
  130. it('rejects missing, unterminated, or duplicated sections', () => {
  131. expect(() => parseTranslationResponse('<translation>\nA\n</translation>')).toThrow(/missing or unterminated <review>/)
  132. expect(() => parseTranslationResponse('<translation>\nA')).toThrow(/missing or unterminated <translation>/)
  133. const dup = '<translation>\nA\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>\n<final>\nG\n</final>'
  134. expect(() => parseTranslationResponse(dup)).toThrow(/duplicate <final>/)
  135. expect(() => parseTranslationResponse(`${renderTranslationResponse({ translation: 'A', review: 'R', final: 'F' })}\nstray`))
  136. .toThrow(/content is not allowed outside/)
  137. })
  138. it('inserts or corrects the target switcher after parsing a new-pair response', () => {
  139. const response = renderTranslationResponse({
  140. translation: '# 指南\n\n初稿。',
  141. review: '- 无修正',
  142. final: '# 指南\n\nEnglish | [中文](guide.zh.md)\n\n定稿。',
  143. })
  144. expect(consumeTranslationResponse(response, { sourceLanguage: 'English', sourceFilename: 'guide.md' }).final).toBe([
  145. '# 指南',
  146. '',
  147. '[English](guide.md) | 中文',
  148. '',
  149. '定稿。',
  150. '',
  151. ].join('\n'))
  152. })
  153. it('preserves YAML frontmatter before inserting the target switcher', () => {
  154. const response = renderTranslationResponse({
  155. translation: '# 指南\n\n初稿。',
  156. review: '- 无修正',
  157. final: [
  158. '---',
  159. 'layout: home',
  160. '---',
  161. '',
  162. '# 指南',
  163. '',
  164. '定稿。',
  165. ].join('\n'),
  166. })
  167. expect(consumeTranslationResponse(response, { sourceLanguage: 'English', sourceFilename: 'guide.md' }).final).toBe([
  168. '---',
  169. 'layout: home',
  170. '---',
  171. '',
  172. '# 指南',
  173. '',
  174. '[English](guide.md) | 中文',
  175. '',
  176. '定稿。',
  177. '',
  178. ].join('\n'))
  179. })
  180. it('rejects unterminated YAML frontmatter before the target H1', () => {
  181. const response = renderTranslationResponse({
  182. translation: '# 指南\n\n初稿。',
  183. review: '- 无修正',
  184. final: '---\nlayout: home\n\n# 指南\n\n定稿。',
  185. })
  186. expect(() => consumeTranslationResponse(response, {
  187. sourceLanguage: 'English',
  188. sourceFilename: 'guide.md',
  189. })).toThrow(/unterminated YAML frontmatter/)
  190. })
  191. it('rejects a source filename that contradicts the translation direction', () => {
  192. expect(() => renderTranslationPrompt(document, {
  193. sourceLanguage: 'Chinese',
  194. sourceFilename: 'guide.md',
  195. terminology,
  196. })).toThrow(/does not match source language Chinese/)
  197. })
  198. it('inserts the English target switcher for a Chinese source', () => {
  199. const response = renderTranslationResponse({
  200. translation: '# Guide\n\nDraft.',
  201. review: '- [None] No corrections.',
  202. final: '# Guide\n\nFinal.',
  203. })
  204. expect(consumeTranslationResponse(response, {
  205. sourceLanguage: 'Chinese',
  206. sourceFilename: 'guide.zh.md',
  207. }).final).toContain('\n\nEnglish | [中文](guide.zh.md)\n\n')
  208. })
  209. })