gen-cordis-catalog-record.spec.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. /**
  2. * Negative-path coverage for the guarded pair auto-record
  3. * (`maybeRecordPair`): the safety property is that regeneration re-records a
  4. * pair's `.i18n.yaml` ONLY for a region-confined write over a well-formed,
  5. * previously-consistent record — every other state is left for the pairing
  6. * gate to report.
  7. */
  8. import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
  9. import { tmpdir } from 'node:os'
  10. import { join } from 'node:path'
  11. import { afterEach, describe, expect, it } from 'vitest'
  12. import {
  13. localizePageRegion,
  14. maybeRecordPair,
  15. REGION_BEGIN,
  16. REGION_END,
  17. spliceRegion,
  18. } from './gen-cordis-catalog.ts'
  19. import { blobHash, renderPairMeta } from './translation-pairing.ts'
  20. const PAGE = 'docs/subsystems/fix.md'
  21. const ZH = 'docs/subsystems/fix.zh.md'
  22. const META = 'docs/subsystems/fix.i18n.yaml'
  23. function page(prose: string, region: string): string {
  24. return `# Fix\n\n${prose}\n\n${REGION_BEGIN}\n${region}\n${REGION_END}\n`
  25. }
  26. const roots: string[] = []
  27. afterEach(() => {
  28. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  29. })
  30. /** Lay out a pair on disk and return { root, before } for a regeneration that already wrote `current`. */
  31. function setup(options: {
  32. beforeEn: string
  33. beforeZh: string
  34. currentEn: string
  35. currentZh: string
  36. meta?: string | null
  37. omitZhSnapshot?: boolean
  38. }): { root: string; before: Map<string, Buffer> } {
  39. const root = mkdtempSync(join(tmpdir(), 'record-guard-'))
  40. roots.push(root)
  41. mkdirSync(join(root, 'docs/subsystems'), { recursive: true })
  42. writeFileSync(join(root, PAGE), options.currentEn)
  43. writeFileSync(join(root, ZH), options.currentZh)
  44. const meta = options.meta === undefined
  45. ? renderPairMeta(PAGE, blobHash(Buffer.from(options.beforeEn)), ZH, blobHash(Buffer.from(options.beforeZh)))
  46. : options.meta
  47. if (meta !== null) writeFileSync(join(root, META), meta)
  48. const before = new Map<string, Buffer>([[PAGE, Buffer.from(options.beforeEn)]])
  49. if (!options.omitZhSnapshot) before.set(ZH, Buffer.from(options.beforeZh))
  50. return { root, before }
  51. }
  52. describe('maybeRecordPair', () => {
  53. const beforeEn = page('prose.', 'old region')
  54. const beforeZh = page('散文。', 'old region')
  55. const currentEn = page('prose.', 'new region')
  56. const currentZh = page('散文。', 'new region')
  57. it('re-records a region-confined write over a consistent record', () => {
  58. const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh })
  59. expect(maybeRecordPair(PAGE, before, root)).toBe(true)
  60. expect(readFileSync(join(root, META), 'utf8'))
  61. .toBe(renderPairMeta(PAGE, blobHash(Buffer.from(currentEn)), ZH, blobHash(Buffer.from(currentZh))))
  62. })
  63. it('refuses when the pair was already out of sync before the run', () => {
  64. const stale = renderPairMeta(PAGE, blobHash(Buffer.from('drifted long ago\n')), ZH, blobHash(Buffer.from(beforeZh)))
  65. const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: stale })
  66. expect(maybeRecordPair(PAGE, before, root)).toBe(false)
  67. expect(readFileSync(join(root, META), 'utf8')).toBe(stale)
  68. })
  69. it('refuses a malformed record even when its hashes are current', () => {
  70. // A renamed key with preserved hashes must stay the pairing gate's error,
  71. // never become valid through regeneration.
  72. const renamedKeys = [
  73. '# comment',
  74. `fixXmd: ${blobHash(Buffer.from(beforeEn))}`,
  75. `fix.zh.md: ${blobHash(Buffer.from(beforeZh))}`,
  76. '',
  77. ].join('\n')
  78. const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: renamedKeys })
  79. expect(maybeRecordPair(PAGE, before, root)).toBe(false)
  80. expect(readFileSync(join(root, META), 'utf8')).toBe(renamedKeys)
  81. })
  82. it('refuses a record with extra entries', () => {
  83. const extra = renderPairMeta(PAGE, blobHash(Buffer.from(beforeEn)), ZH, blobHash(Buffer.from(beforeZh)))
  84. + `other.md: ${blobHash(Buffer.from(beforeEn))}\n`
  85. const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: extra })
  86. expect(maybeRecordPair(PAGE, before, root)).toBe(false)
  87. })
  88. it('refuses a record with a duplicated expected key', () => {
  89. // Map#set would collapse the duplicate back to size 2; the parser must
  90. // reject the repeat instead of letting the guard accept the record.
  91. const duplicated = [
  92. `fix.md: ${blobHash(Buffer.from(beforeEn))}`,
  93. `fix.md: ${blobHash(Buffer.from(beforeEn))}`,
  94. `fix.zh.md: ${blobHash(Buffer.from(beforeZh))}`,
  95. '',
  96. ].join('\n')
  97. const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: duplicated })
  98. expect(maybeRecordPair(PAGE, before, root)).toBe(false)
  99. expect(readFileSync(join(root, META), 'utf8')).toBe(duplicated)
  100. })
  101. it('refuses when prose drifted alongside the region write', () => {
  102. const proseDrift = page('prose, edited by a human.', 'new region')
  103. const { root, before } = setup({ beforeEn, beforeZh, currentEn: proseDrift, currentZh })
  104. expect(maybeRecordPair(PAGE, before, root)).toBe(false)
  105. })
  106. it('refuses a brand-new pair with no record', () => {
  107. const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, meta: null })
  108. expect(maybeRecordPair(PAGE, before, root)).toBe(false)
  109. })
  110. it('refuses when a side has no pre-write snapshot', () => {
  111. const { root, before } = setup({ beforeEn, beforeZh, currentEn, currentZh, omitZhSnapshot: true })
  112. expect(maybeRecordPair(PAGE, before, root)).toBe(false)
  113. })
  114. })
  115. describe('spliceRegion', () => {
  116. it('replaces exactly the cordis-surface region', () => {
  117. const doc = `# T\n\nprose\n\n${REGION_BEGIN}\nold\n${REGION_END}\ntail\n`
  118. expect(spliceRegion(doc, `${REGION_BEGIN}\nnew\n${REGION_END}`))
  119. .toBe(`# T\n\nprose\n\n${REGION_BEGIN}\nnew\n${REGION_END}\ntail\n`)
  120. })
  121. it('fails loud on a page carrying only some other generator\'s region', () => {
  122. // Another generator's markers satisfy the generic region grammar but must
  123. // never be overwritten by THIS generator's splice.
  124. const foreign = '# T\n\n<!-- BEGIN GENERATED other-surface (other-gen.ts) — do not edit between markers -->\ntheirs\n<!-- END GENERATED other-surface -->\n'
  125. expect(() => spliceRegion(foreign, `${REGION_BEGIN}\nnew\n${REGION_END}`))
  126. .toThrow('expected exactly 1 cordis-surface region, found 0 BEGIN/0 END')
  127. })
  128. it('fails loud on duplicate cordis-surface markers', () => {
  129. const doubled = `${REGION_BEGIN}\na\n${REGION_END}\n${REGION_BEGIN}\nb\n${REGION_END}\n`
  130. expect(() => spliceRegion(doubled, `${REGION_BEGIN}\nnew\n${REGION_END}`))
  131. .toThrow('found 2 BEGIN/2 END')
  132. })
  133. })
  134. describe('localizePageRegion', () => {
  135. it('changes only paired Markdown paths for the Chinese generated region', () => {
  136. const root = mkdtempSync(join(tmpdir(), 'cordis-region-locale-'))
  137. roots.push(root)
  138. mkdirSync(join(root, 'docs/subsystems'), { recursive: true })
  139. mkdirSync(join(root, 'packages'), { recursive: true })
  140. mkdirSync(join(root, 'scripts'), { recursive: true })
  141. writeFileSync(join(root, 'docs/subsystems/target.md'), '# Target\n')
  142. writeFileSync(join(root, 'docs/subsystems/target.zh.md'), '# 目标\n')
  143. writeFileSync(join(root, 'docs/subsystems/excluded.md'), '# Excluded\n')
  144. writeFileSync(join(root, 'docs/subsystems/excluded.zh.md'), '# 排除\n')
  145. writeFileSync(join(root, 'packages/outside.md'), '# Outside\n')
  146. writeFileSync(join(root, 'packages/outside.zh.md'), '# 范围外\n')
  147. writeFileSync(join(root, 'scripts/translation-pairing.manifest.json'), JSON.stringify({
  148. excluded: ['docs/subsystems/excluded.md'],
  149. }))
  150. const region = `${REGION_BEGIN}\n[Target](target.md#api) [Excluded](excluded.md) [Outside](../../packages/outside.md)\n${REGION_END}`
  151. expect(localizePageRegion(region, 'docs/subsystems/page.md', root)).toBe(region)
  152. expect(localizePageRegion(region, 'docs/subsystems/page.zh.md', root)).toBe(
  153. `${REGION_BEGIN}\n[Target](target.zh.md#api) [Excluded](excluded.md) [Outside](../../packages/outside.md)\n${REGION_END}`,
  154. )
  155. })
  156. })