translation-pairing.spec.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /** Regression tests for bilingual snapshots, corpus scope, and structure. */
  2. import { execFileSync, spawnSync } from 'node:child_process'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { describe, expect, it } from 'vitest'
  7. import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
  8. import {
  9. isTranslationScopeFile,
  10. pairAnchorOfArgument,
  11. parseTranslationMarkdown,
  12. parseTranslationPairingCliArgs,
  13. parseTranslationPairingManifest,
  14. translationStructureDiff,
  15. translationStructureSignature,
  16. } from './translation-pairing.ts'
  17. function signature(markdown: string) {
  18. return translationStructureSignature(parseTranslationMarkdown(markdown), 'counterpart.zh.md')
  19. }
  20. function gitSupportsObjectFormat(format: 'sha256'): boolean {
  21. const root = mkdtempSync(join(tmpdir(), 'dsh-git-object-format-'))
  22. try {
  23. return spawnSync('git', ['init', '--quiet', `--object-format=${format}`, root], {
  24. stdio: 'ignore',
  25. }).status === 0
  26. } finally {
  27. rmSync(root, { recursive: true, force: true })
  28. }
  29. }
  30. const supportsSha256ObjectFormat = gitSupportsObjectFormat('sha256')
  31. describe('translation pairing snapshots', () => {
  32. it('stores exact uncommitted bytes for later recovery by object ID', () => {
  33. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  34. try {
  35. execFileSync('git', ['init', '--quiet', root], {
  36. env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
  37. })
  38. const content = Buffer.from([0x75, 0x6e, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x0a, 0xff])
  39. const objectId = storeGitBlob(root, content)
  40. expect(objectId).toBe(gitBlobHash(content))
  41. expect(execFileSync('git', [
  42. '-C', root, 'rev-parse', `refs/dsh/translation-pairing/snapshots/${objectId}`,
  43. ], { encoding: 'utf8' }).trim()).toBe(objectId)
  44. execFileSync('git', ['-C', root, 'gc', '--prune=now'])
  45. expect(execFileSync('git', ['-C', root, 'cat-file', '-p', objectId])).toEqual(content)
  46. } finally {
  47. rmSync(root, { recursive: true, force: true })
  48. }
  49. })
  50. it('fails before a sidecar can reference an unavailable object', () => {
  51. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  52. try {
  53. expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
  54. } finally {
  55. rmSync(root, { recursive: true, force: true })
  56. }
  57. })
  58. it('fails clearly when Git cannot be started', () => {
  59. const previousPath = process.env.PATH
  60. try {
  61. process.env.PATH = ''
  62. expect(() => storeGitBlob('.', Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
  63. } finally {
  64. process.env.PATH = previousPath
  65. }
  66. })
  67. it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => {
  68. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  69. try {
  70. execFileSync('git', ['init', '--quiet', '--object-format=sha256', root])
  71. expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('returned unexpected object ID')
  72. } finally {
  73. rmSync(root, { recursive: true, force: true })
  74. }
  75. })
  76. })
  77. describe('translation pairing manifest', () => {
  78. it('accepts an exclusions-only manifest', () => {
  79. expect(parseTranslationPairingManifest(JSON.stringify({
  80. excluded: ['docs/generated/'],
  81. }))).toEqual({
  82. excluded: ['docs/generated/'],
  83. })
  84. })
  85. it.each([
  86. ['required', ['packages/README.md']],
  87. ['requiredClasses', ['readme']],
  88. ['requiredSince', '2026-07-14'],
  89. ] as const)('rejects obsolete policy field %s instead of accepting an inert requirement', (field, value) => {
  90. expect(() => parseTranslationPairingManifest(JSON.stringify({
  91. excluded: [],
  92. [field]: value,
  93. }))).toThrow(`unsupported field(s): ${field}; every in-scope document is required`)
  94. })
  95. it('rejects a missing or non-string exclusion list', () => {
  96. expect(() => parseTranslationPairingManifest('{}')).toThrow('excluded must be an array of strings')
  97. expect(() => parseTranslationPairingManifest(JSON.stringify({
  98. excluded: [42],
  99. }))).toThrow('excluded must be an array of strings')
  100. })
  101. })
  102. describe('translation scope discovery', () => {
  103. it.each([
  104. 'README.md',
  105. 'apps/cli/README.md',
  106. 'future/subtree/readme.md',
  107. 'packages/example/README.zh.md',
  108. 'native/example/README.i18n.yaml',
  109. '.agents/notes/proposed/feature.md',
  110. 'docs/guide.md',
  111. 'python/guide.md',
  112. ])('includes %s', (file) => {
  113. expect(isTranslationScopeFile(file)).toBe(true)
  114. })
  115. it.each([
  116. 'packages/example/guide.md',
  117. 'examples/tutorial.md',
  118. 'website/reference.md',
  119. 'packages/example/README.txt',
  120. 'vendor/example/README.md',
  121. 'packages/example/node_modules/dependency/README.md',
  122. 'packages/example/lib/README.md',
  123. 'coverage/report/README.md',
  124. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-macos-arm64/README.md',
  125. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md',
  126. ])('excludes non-source or non-README path %s', (file) => {
  127. expect(isTranslationScopeFile(file)).toBe(false)
  128. })
  129. })
  130. describe('translation structural signature', () => {
  131. it('accepts matching list kinds, starts, and item counts', () => {
  132. const source = signature('3. One\n4. Two\n\n- A\n- B\n')
  133. const counterpart = signature('3. 一\n4. 二\n\n- 甲\n- 乙\n')
  134. expect(translationStructureDiff(source, counterpart)).toEqual([])
  135. })
  136. it('rejects an altered ordered-list start', () => {
  137. const source = signature('3. One\n4. Two\n\n- A\n- B\n')
  138. const counterpart = signature('1. 一\n2. 二\n\n- 甲\n- 乙\n')
  139. expect(translationStructureDiff(source, counterpart)).toEqual([
  140. 'list (kind, start, item count) #1 diverges between the pair: "ordered:start=3:items=2" vs "ordered:start=1:items=2"',
  141. ])
  142. })
  143. it('rejects a missing list item', () => {
  144. const source = signature('- A\n- B\n')
  145. const counterpart = signature('- 甲\n')
  146. expect(translationStructureDiff(source, counterpart)).toEqual([
  147. 'list (kind, start, item count) #1 diverges between the pair: "bullet:items=2" vs "bullet:items=1"',
  148. ])
  149. })
  150. it('rejects altered table row or column counts', () => {
  151. const source = signature('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n')
  152. const counterpart = signature('| 甲 | 乙 |\n|---|---|\n| 一 | 二 |\n')
  153. expect(translationStructureDiff(source, counterpart)).toEqual([
  154. 'table (row x column count) #1 diverges between the pair: "3x2" vs "2x2"',
  155. ])
  156. })
  157. })
  158. describe('pair CLI arguments', () => {
  159. it('normalizes any pair file or bare stem to the English anchor', () => {
  160. expect(pairAnchorOfArgument('docs/foo.md')).toBe('docs/foo.md')
  161. expect(pairAnchorOfArgument('docs/foo.zh.md')).toBe('docs/foo.md')
  162. expect(pairAnchorOfArgument('docs/foo.i18n.yaml')).toBe('docs/foo.md')
  163. expect(pairAnchorOfArgument('docs/foo')).toBe('docs/foo.md')
  164. expect(pairAnchorOfArgument('.\\docs\\foo.zh.md')).toBe('docs/foo.md')
  165. })
  166. it('scopes a check to named pairs and dedupes the three spellings', () => {
  167. expect(parseTranslationPairingCliArgs(['docs/foo.zh.md', 'docs/foo.i18n.yaml', 'docs/bar.md'])).toEqual({
  168. mode: 'check',
  169. scope: 'pairs',
  170. anchors: ['docs/bar.md', 'docs/foo.md'],
  171. })
  172. expect(parseTranslationPairingCliArgs([])).toEqual({ mode: 'check', scope: 'corpus', anchors: [] })
  173. })
  174. it('requires --write to name confirmed pairs or opt into --all', () => {
  175. expect(() => parseTranslationPairingCliArgs(['--write'])).toThrow('requires the pair(s) you confirmed')
  176. expect(parseTranslationPairingCliArgs(['--write', 'docs/foo.md'])).toEqual({
  177. mode: 'write',
  178. scope: 'pairs',
  179. anchors: ['docs/foo.md'],
  180. })
  181. expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({ mode: 'write', scope: 'corpus', anchors: [] })
  182. expect(() => parseTranslationPairingCliArgs(['--write', '--all', 'docs/foo.md'])).toThrow('not both')
  183. })
  184. it('keeps --list corpus-only and rejects unknown flags', () => {
  185. expect(parseTranslationPairingCliArgs(['--list'])).toEqual({ mode: 'list', scope: 'corpus', anchors: [] })
  186. expect(() => parseTranslationPairingCliArgs(['--list', 'docs/foo.md'])).toThrow('takes no other flags or paths')
  187. expect(() => parseTranslationPairingCliArgs(['--all'])).toThrow('--all only applies to --write')
  188. expect(() => parseTranslationPairingCliArgs(['--frobnicate'])).toThrow('unknown flag(s): --frobnicate')
  189. })
  190. })