translation-pairing.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. /** Regression tests for bilingual snapshots, corpus scope, and structure. */
  2. import { execFileSync, spawnSync } from 'node:child_process'
  3. import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { describe, expect, it } from 'vitest'
  7. import {
  8. gitBlobHash,
  9. gitIndexPaths,
  10. readGitIndexBlob,
  11. storeGitBlob,
  12. } from './translation-pairing-git.ts'
  13. import {
  14. parseTranslationPairingRecord,
  15. renderTranslationPairingRecord,
  16. translationPairPaths,
  17. } from './translation-pairing-record.ts'
  18. import {
  19. blobHash,
  20. isTranslationPairingManifestExcluded,
  21. isTranslationScopeFile,
  22. languageSwitcherTargets,
  23. pairAnchorOfArgument,
  24. parseTranslationMarkdown,
  25. parseTranslationPairingCliArgs,
  26. parseTranslationPairingManifest,
  27. partitionGeneratedRegions,
  28. requiresSourceLanguageSwitcher,
  29. translationPairSourcePredicate,
  30. translationStructureDiff,
  31. translationStructureSignature,
  32. } from './translation-pairing.ts'
  33. const fixturePairSource = (): boolean => true
  34. function signature(markdown: string) {
  35. return translationStructureSignature(
  36. parseTranslationMarkdown(markdown),
  37. 'counterpart.zh.md',
  38. {
  39. repoRoot: process.cwd(), sourcePath: 'counterpart.md',
  40. isTranslationPairSource: fixturePairSource, repositoryFileExists: () => true, markdown,
  41. },
  42. )
  43. }
  44. function fixtureSignature(
  45. root: string,
  46. sourcePath: string,
  47. markdown: string,
  48. switcherTarget: string,
  49. ) {
  50. return translationStructureSignature(
  51. parseTranslationMarkdown(markdown),
  52. switcherTarget,
  53. { repoRoot: root, sourcePath, isTranslationPairSource: fixturePairSource, markdown },
  54. )
  55. }
  56. function gitSupportsObjectFormat(format: 'sha256'): boolean {
  57. const root = mkdtempSync(join(tmpdir(), 'dsh-git-object-format-'))
  58. try {
  59. return spawnSync('git', ['init', '--quiet', `--object-format=${format}`, root], {
  60. stdio: 'ignore',
  61. }).status === 0
  62. } finally {
  63. rmSync(root, { recursive: true, force: true })
  64. }
  65. }
  66. const supportsSha256ObjectFormat = gitSupportsObjectFormat('sha256')
  67. describe('translation pairing snapshots', () => {
  68. it('stores exact uncommitted bytes for later recovery by object ID', () => {
  69. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  70. try {
  71. execFileSync('git', ['init', '--quiet', root], {
  72. env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
  73. })
  74. const content = Buffer.from([0x75, 0x6e, 0x63, 0x6f, 0x6d, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x0a, 0xff])
  75. const objectId = storeGitBlob(root, content)
  76. expect(objectId).toBe(gitBlobHash(content))
  77. expect(execFileSync('git', [
  78. '-C', root, 'rev-parse', `refs/dsh/translation-pairing/snapshots/${objectId}`,
  79. ], { encoding: 'utf8' }).trim()).toBe(objectId)
  80. execFileSync('git', ['-C', root, 'gc', '--prune=now'])
  81. expect(execFileSync('git', ['-C', root, 'cat-file', '-p', objectId])).toEqual(content)
  82. } finally {
  83. rmSync(root, { recursive: true, force: true })
  84. }
  85. })
  86. it('fails before a sidecar can reference an unavailable object', () => {
  87. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  88. try {
  89. expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
  90. } finally {
  91. rmSync(root, { recursive: true, force: true })
  92. }
  93. })
  94. it('fails clearly when Git cannot be started', () => {
  95. const previousPath = process.env.PATH
  96. try {
  97. process.env.PATH = ''
  98. expect(() => storeGitBlob('.', Buffer.from('snapshot'))).toThrow('git hash-object -w --stdin failed')
  99. } finally {
  100. process.env.PATH = previousPath
  101. }
  102. })
  103. it('reads staged bytes independently of the working tree', () => {
  104. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-index-'))
  105. try {
  106. execFileSync('git', ['init', '--quiet', root], {
  107. env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
  108. })
  109. execFileSync('git', ['-C', root, 'config', 'user.email', 'pairing@example.test'])
  110. execFileSync('git', ['-C', root, 'config', 'user.name', 'Pairing Test'])
  111. writeFileSync(join(root, 'owner.md'), 'staged')
  112. execFileSync('git', ['-C', root, 'add', 'owner.md'])
  113. writeFileSync(join(root, 'owner.md'), 'unstaged')
  114. const indexed = readGitIndexBlob(root, 'owner.md')
  115. expect(indexed?.content.toString('utf8')).toBe('staged')
  116. expect(indexed?.objectId).toBe(gitBlobHash(Buffer.from('staged')))
  117. expect(readGitIndexBlob(root, 'absent.md')).toBeUndefined()
  118. } finally {
  119. rmSync(root, { recursive: true, force: true })
  120. }
  121. })
  122. it('lists exact index files without treating a directory prefix as one entry', () => {
  123. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-index-'))
  124. try {
  125. execFileSync('git', ['init', '--quiet', root], {
  126. env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
  127. })
  128. mkdirSync(join(root, 'docs'), { recursive: true })
  129. writeFileSync(join(root, 'docs/reference.md'), '# Reference\n')
  130. writeFileSync(join(root, 'docs/reference.zh.md'), '# 参考\n')
  131. execFileSync('git', ['-C', root, 'add', 'docs'])
  132. expect(gitIndexPaths(root)).toEqual(new Set([
  133. 'docs/reference.md',
  134. 'docs/reference.zh.md',
  135. ]))
  136. } finally {
  137. rmSync(root, { recursive: true, force: true })
  138. }
  139. })
  140. it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => {
  141. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
  142. try {
  143. execFileSync('git', ['init', '--quiet', '--object-format=sha256', root])
  144. expect(() => storeGitBlob(root, Buffer.from('snapshot'))).toThrow('returned unexpected object ID')
  145. } finally {
  146. rmSync(root, { recursive: true, force: true })
  147. }
  148. })
  149. })
  150. describe('translation pairing manifest', () => {
  151. it('accepts an exclusions-only manifest', () => {
  152. const manifest = parseTranslationPairingManifest(JSON.stringify({
  153. excluded: ['docs/generated/'],
  154. }))
  155. expect(manifest).toEqual({
  156. excluded: ['docs/generated/'],
  157. })
  158. expect(isTranslationPairingManifestExcluded('docs/generated/page.md', manifest)).toBe(true)
  159. expect(translationPairSourcePredicate(manifest)('docs/generated/page.md')).toBe(false)
  160. expect(translationPairSourcePredicate(manifest)('docs/guide.md')).toBe(true)
  161. expect(translationPairSourcePredicate(manifest)('packages/example/guide.md')).toBe(false)
  162. })
  163. it.each([
  164. ['required', ['packages/README.md']],
  165. ['requiredClasses', ['readme']],
  166. ['requiredSince', '2026-07-14'],
  167. ] as const)('rejects obsolete policy field %s instead of accepting an inert requirement', (field, value) => {
  168. expect(() => parseTranslationPairingManifest(JSON.stringify({
  169. excluded: [],
  170. [field]: value,
  171. }))).toThrow(`unsupported field(s): ${field}; every in-scope document is required`)
  172. })
  173. it('rejects a missing or non-string exclusion list', () => {
  174. expect(() => parseTranslationPairingManifest('{}')).toThrow('excluded must be an array of strings')
  175. expect(() => parseTranslationPairingManifest(JSON.stringify({
  176. excluded: [42],
  177. }))).toThrow('excluded must be an array of strings')
  178. })
  179. })
  180. describe('translation pairing switchers', () => {
  181. it('exempts only paired generated English sources from reciprocal switchers', () => {
  182. expect(requiresSourceLanguageSwitcher('docs/config-catalog.md')).toBe(false)
  183. expect(requiresSourceLanguageSwitcher('docs/cordis-api/context.md')).toBe(false)
  184. expect(requiresSourceLanguageSwitcher('docs/cordis-api/inherited.md')).toBe(false)
  185. expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true)
  186. expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true)
  187. })
  188. it('accepts only the canonical public URL for an absolute switcher', () => {
  189. const targets = languageSwitcherTargets('python/sdk/README.zh.md')
  190. const canonicalMarkdown = '# README\n\nEnglish | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/python/sdk/README.zh.md)\n'
  191. const canonical = parseTranslationMarkdown(canonicalMarkdown)
  192. const wrongMarkdown = '# README\n\nEnglish | [中文](https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md)\n'
  193. const wrongPath = parseTranslationMarkdown(wrongMarkdown)
  194. expect(translationStructureSignature(canonical, targets, {
  195. repoRoot: process.cwd(),
  196. sourcePath: 'python/sdk/README.md',
  197. isTranslationPairSource: fixturePairSource,
  198. markdown: canonicalMarkdown,
  199. }).links).toEqual([])
  200. expect(translationStructureSignature(wrongPath, targets, {
  201. repoRoot: process.cwd(),
  202. sourcePath: 'python/sdk/README.md',
  203. isTranslationPairSource: fixturePairSource,
  204. markdown: wrongMarkdown,
  205. }).links).toEqual([
  206. 'https://github.com/deepseek-ai/deepseek-harness/blob/master/other/README.zh.md',
  207. ])
  208. })
  209. it('excludes only the header switcher from the structural links', () => {
  210. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-switcher-'))
  211. try {
  212. writeFileSync(join(root, 'guide.md'), '# Guide\n')
  213. writeFileSync(join(root, 'guide.zh.md'), '# 指南\n')
  214. const markdown = '# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.md)\n'
  215. expect(translationStructureSignature(
  216. parseTranslationMarkdown(markdown),
  217. languageSwitcherTargets('guide.md'),
  218. {
  219. repoRoot: root, sourcePath: 'guide.zh.md',
  220. isTranslationPairSource: fixturePairSource, markdown,
  221. },
  222. ).links).toEqual(['dsh-translation-target:guide.md'])
  223. } finally {
  224. rmSync(root, { recursive: true, force: true })
  225. }
  226. })
  227. })
  228. describe('translation pairing link language parity', () => {
  229. it('compares a .zh.md target and its .md sibling as the same document', () => {
  230. const en = 'See [docs](persistence.md) and [notes](note.md#anchor).'
  231. const zh = '参见[文档](persistence.zh.md)与[笔记](note.zh.md#anchor)。'
  232. expect(
  233. translationStructureDiff(
  234. signature(en),
  235. signature(zh),
  236. ),
  237. ).toEqual([])
  238. })
  239. it('still rejects a genuinely different target', () => {
  240. const en = 'See [docs](persistence.md).'
  241. const zh = '参见[文档](other.md)。'
  242. expect(
  243. translationStructureDiff(
  244. signature(en),
  245. signature(zh),
  246. ),
  247. ).not.toEqual([])
  248. })
  249. })
  250. describe('translation pairing records', () => {
  251. const paths = translationPairPaths('docs/foo.md')
  252. const record = {
  253. sourceHash: '1'.repeat(40),
  254. zhHash: '2'.repeat(40),
  255. }
  256. it('round-trips the canonical two-hash record', () => {
  257. expect(parseTranslationPairingRecord(renderTranslationPairingRecord(paths, record), paths)).toEqual(record)
  258. })
  259. it('rejects duplicate or unexpected keys', () => {
  260. expect(parseTranslationPairingRecord([
  261. `foo.md: ${'1'.repeat(40)}`,
  262. `foo.md: ${'3'.repeat(40)}`,
  263. `foo.zh.md: ${'2'.repeat(40)}`,
  264. '',
  265. ].join('\n'), paths)).toBeUndefined()
  266. expect(parseTranslationPairingRecord([
  267. `foo.md: ${'1'.repeat(40)}`,
  268. `bar.zh.md: ${'2'.repeat(40)}`,
  269. '',
  270. ].join('\n'), paths)).toBeUndefined()
  271. })
  272. })
  273. describe('translation scope discovery', () => {
  274. it.each([
  275. 'README.md',
  276. 'CONTRIBUTING.md',
  277. 'CONTRIBUTING.zh.md',
  278. 'CONTRIBUTING.i18n.yaml',
  279. 'BRAND_GUIDELINES.md',
  280. 'BRAND_GUIDELINES.zh.md',
  281. 'BRAND_GUIDELINES.i18n.yaml',
  282. 'SAFETY.md',
  283. 'SAFETY.zh.md',
  284. 'SAFETY.i18n.yaml',
  285. 'apps/cli/README.md',
  286. 'future/subtree/readme.md',
  287. 'packages/example/README.zh.md',
  288. 'native/example/README.i18n.yaml',
  289. '.agents/notes/proposed/feature.md',
  290. 'docs/guide.md',
  291. 'python/guide.md',
  292. ])('includes %s', (file) => {
  293. expect(isTranslationScopeFile(file)).toBe(true)
  294. })
  295. it.each([
  296. 'packages/example/guide.md',
  297. 'packages/example/CONTRIBUTING.md',
  298. 'packages/example/BRAND_GUIDELINES.md',
  299. 'other/tutorial.md',
  300. 'website/reference.md',
  301. 'packages/example/README.txt',
  302. 'vendor/example/README.md',
  303. 'packages/example/node_modules/dependency/README.md',
  304. 'packages/example/lib/README.md',
  305. 'coverage/report/README.md',
  306. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-macos-arm64/README.md',
  307. 'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md',
  308. ])('excludes non-source or non-README path %s', (file) => {
  309. expect(isTranslationScopeFile(file)).toBe(false)
  310. })
  311. })
  312. describe('translation structural signature', () => {
  313. it('retains external GFM autolinks without parsing inline-link syntax', () => {
  314. const markdown = '<https://example.com/reference.md>\n'
  315. expect(signature(markdown).links).toEqual(['https://example.com/reference.md'])
  316. })
  317. it('retains exact authored bytes for ordinary external link targets', () => {
  318. const escaped = signature('[External](https://example.com/?x=1&amp;y=2)\n')
  319. const literal = signature('[External](https://example.com/?x=1&y=2)\n')
  320. expect(escaped.links).toEqual(['https://example.com/?x=1&amp;y=2'])
  321. expect(translationStructureDiff(escaped, literal)).toEqual([
  322. 'link target #1 diverges between the pair: "https://example.com/?x=1&amp;y=2" vs "https://example.com/?x=1&y=2"',
  323. ])
  324. })
  325. it('treats target-locale siblings as one semantic link target', () => {
  326. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-structure-'))
  327. try {
  328. writeFileSync(join(root, 'reference.md'), '# Reference\n')
  329. writeFileSync(join(root, 'reference.zh.md'), '# 参考\n')
  330. const sourceMarkdown = '[Reference](reference.md?view=full#section)\n'
  331. const counterpartMarkdown = '[参考](reference.zh.md?view=full#section)\n'
  332. const source = fixtureSignature(root, 'guide.md', sourceMarkdown, 'guide.zh.md')
  333. const counterpart = fixtureSignature(root, 'guide.zh.md', counterpartMarkdown, 'guide.md')
  334. expect(translationStructureDiff(source, counterpart)).toEqual([])
  335. } finally {
  336. rmSync(root, { recursive: true, force: true })
  337. }
  338. })
  339. it('includes reference-style document links but excludes image-only definitions', () => {
  340. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-structure-'))
  341. try {
  342. writeFileSync(join(root, 'reference.md'), '# Reference\n')
  343. writeFileSync(join(root, 'reference.zh.md'), '# 参考\n')
  344. const markdown = [
  345. '[Reference][doc]',
  346. '',
  347. '![Preview][asset]',
  348. '',
  349. '[doc]: reference.md',
  350. '[asset]: reference.zh.md',
  351. '',
  352. ].join('\n')
  353. expect(translationStructureSignature(
  354. parseTranslationMarkdown(markdown),
  355. 'guide.zh.md',
  356. {
  357. repoRoot: root, sourcePath: 'guide.md',
  358. isTranslationPairSource: fixturePairSource, markdown,
  359. },
  360. ).links).toEqual(['dsh-translation-target:reference.md'])
  361. } finally {
  362. rmSync(root, { recursive: true, force: true })
  363. }
  364. })
  365. it('compares the first duplicate reference definition that CommonMark resolves', () => {
  366. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-structure-'))
  367. try {
  368. for (const name of ['reference', 'different', 'other']) {
  369. writeFileSync(join(root, `${name}.md`), `# ${name}\n`)
  370. writeFileSync(join(root, `${name}.zh.md`), `# ${name} zh\n`)
  371. }
  372. const sourceMarkdown = '[Reference][ref]\n\n[ref]: reference.md\n[ref]: other.md\n'
  373. const counterpartMarkdown = '[参考][ref]\n\n[ref]: different.zh.md\n[ref]: other.zh.md\n'
  374. const source = fixtureSignature(root, 'guide.md', sourceMarkdown, 'guide.zh.md')
  375. const counterpart = fixtureSignature(root, 'guide.zh.md', counterpartMarkdown, 'guide.md')
  376. expect(translationStructureDiff(source, counterpart)).toEqual([
  377. 'link target #1 diverges between the pair: "dsh-translation-target:reference.md" vs "dsh-translation-target:different.md"',
  378. ])
  379. } finally {
  380. rmSync(root, { recursive: true, force: true })
  381. }
  382. })
  383. it('accepts matching list kinds, starts, and item counts', () => {
  384. const source = signature('3. One\n4. Two\n\n- A\n- B\n')
  385. const counterpart = signature('3. 一\n4. 二\n\n- 甲\n- 乙\n')
  386. expect(translationStructureDiff(source, counterpart)).toEqual([])
  387. })
  388. it('rejects an altered ordered-list start', () => {
  389. const source = signature('3. One\n4. Two\n\n- A\n- B\n')
  390. const counterpart = signature('1. 一\n2. 二\n\n- 甲\n- 乙\n')
  391. expect(translationStructureDiff(source, counterpart)).toEqual([
  392. 'list (kind, start, item count) #1 diverges between the pair: "ordered:start=3:items=2" vs "ordered:start=1:items=2"',
  393. ])
  394. })
  395. it('rejects a missing list item', () => {
  396. const source = signature('- A\n- B\n')
  397. const counterpart = signature('- 甲\n')
  398. expect(translationStructureDiff(source, counterpart)).toEqual([
  399. 'list (kind, start, item count) #1 diverges between the pair: "bullet:items=2" vs "bullet:items=1"',
  400. ])
  401. })
  402. it('rejects altered table row or column counts', () => {
  403. const source = signature('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n')
  404. const counterpart = signature('| 甲 | 乙 |\n|---|---|\n| 一 | 二 |\n')
  405. expect(translationStructureDiff(source, counterpart)).toEqual([
  406. 'table (row x column count) #1 diverges between the pair: "3x2" vs "2x2"',
  407. ])
  408. })
  409. })
  410. describe('pair CLI arguments', () => {
  411. it('normalizes any pair file or bare stem to the English anchor', () => {
  412. expect(pairAnchorOfArgument('docs/foo.md')).toBe('docs/foo.md')
  413. expect(pairAnchorOfArgument('docs/foo.zh.md')).toBe('docs/foo.md')
  414. expect(pairAnchorOfArgument('docs/foo.i18n.yaml')).toBe('docs/foo.md')
  415. expect(pairAnchorOfArgument('docs/foo')).toBe('docs/foo.md')
  416. expect(pairAnchorOfArgument('.\\docs\\foo.zh.md')).toBe('docs/foo.md')
  417. })
  418. it('scopes a check to named pairs and dedupes the three spellings', () => {
  419. expect(parseTranslationPairingCliArgs(['docs/foo.zh.md', 'docs/foo.i18n.yaml', 'docs/bar.md'])).toEqual({
  420. input: 'worktree',
  421. mode: 'check',
  422. scope: 'pairs',
  423. anchors: ['docs/bar.md', 'docs/foo.md'],
  424. })
  425. expect(parseTranslationPairingCliArgs([])).toEqual({
  426. input: 'worktree',
  427. mode: 'check',
  428. scope: 'corpus',
  429. anchors: [],
  430. })
  431. })
  432. it('requires --write to name confirmed pairs or opt into --all', () => {
  433. expect(() => parseTranslationPairingCliArgs(['--write'])).toThrow('requires the pair(s) you confirmed')
  434. expect(parseTranslationPairingCliArgs(['--write', 'docs/foo.md'])).toEqual({
  435. input: 'worktree',
  436. mode: 'write',
  437. scope: 'pairs',
  438. anchors: ['docs/foo.md'],
  439. })
  440. expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({
  441. input: 'worktree',
  442. mode: 'write',
  443. scope: 'corpus',
  444. anchors: [],
  445. })
  446. expect(() => parseTranslationPairingCliArgs(['--write', '--all', 'docs/foo.md'])).toThrow('not both')
  447. })
  448. it('keeps --list corpus-only and rejects unknown flags', () => {
  449. expect(parseTranslationPairingCliArgs(['--list'])).toEqual({
  450. input: 'worktree',
  451. mode: 'list',
  452. scope: 'corpus',
  453. anchors: [],
  454. })
  455. expect(() => parseTranslationPairingCliArgs(['--list', 'docs/foo.md'])).toThrow('takes no other flags or paths')
  456. expect(() => parseTranslationPairingCliArgs(['--all'])).toThrow('--all only applies to --write')
  457. expect(() => parseTranslationPairingCliArgs(['--frobnicate'])).toThrow('unknown flag(s): --frobnicate')
  458. })
  459. it('makes cached verification a named, read-only index check', () => {
  460. expect(parseTranslationPairingCliArgs(['--cached', 'docs/foo.i18n.yaml'])).toEqual({
  461. input: 'index',
  462. mode: 'check',
  463. scope: 'pairs',
  464. anchors: ['docs/foo.md'],
  465. })
  466. expect(() => parseTranslationPairingCliArgs(['--cached'])).toThrow('requires the staged pair paths')
  467. expect(() => parseTranslationPairingCliArgs(['--cached', '--write', 'docs/foo.md'])).toThrow('read-only')
  468. })
  469. })
  470. describe('generated regions', () => {
  471. const BEGIN = '<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->'
  472. const END = '<!-- END GENERATED cordis-surface -->'
  473. it('partitions marker-delimited regions from the hand-owned remainder', () => {
  474. const doc = `# T\n\nprose\n\n${BEGIN}\ninjected\n${END}\ntail\n`
  475. const { regions, stripped } = partitionGeneratedRegions(doc)
  476. expect(regions).toEqual([`${BEGIN}\ninjected\n${END}`])
  477. expect(stripped).toBe('# T\n\nprose\n\ntail\n')
  478. })
  479. it('treats a document without markers as one hand-owned remainder', () => {
  480. const { regions, stripped } = partitionGeneratedRegions('# T\n\nprose\n')
  481. expect(regions).toEqual([])
  482. expect(stripped).toBe('# T\n\nprose\n')
  483. })
  484. it('rejects unbalanced or nested markers', () => {
  485. expect(() => partitionGeneratedRegions(`${END}\n`)).toThrow('without a BEGIN')
  486. expect(() => partitionGeneratedRegions(`${BEGIN}\n`)).toThrow('without an END')
  487. expect(() => partitionGeneratedRegions(`${BEGIN}\n${BEGIN}\n${END}\n`)).toThrow('nested')
  488. })
  489. it('rejects mismatched slugs and malformed marker lines', () => {
  490. expect(() => partitionGeneratedRegions('<!-- BEGIN GENERATED a -->\nx\n<!-- END GENERATED b -->\n'))
  491. .toThrow("END slug 'b' does not match its BEGIN slug 'a'")
  492. expect(() => partitionGeneratedRegions('<!-- BEGIN GENERATED a --> trailing\nx\n<!-- END GENERATED a -->\n'))
  493. .toThrow('malformed generated region marker line')
  494. expect(() => partitionGeneratedRegions('x\n<!-- END GENERATED a --> tail\n'))
  495. .toThrow('malformed generated region marker line')
  496. })
  497. it('computes the exact git blob hash', () => {
  498. // `git hash-object` of the empty file and of "x\n" — pinned upstream values.
  499. expect(blobHash(Buffer.from(''))).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391')
  500. expect(blobHash(Buffer.from('x\n'))).toBe('587be6b4c3f93f93c489c0111bba5596147a26cb')
  501. })
  502. })