f6-atomic.spec.ts 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
  1. /**
  2. * F6 验收:跨盘原子写入。
  3. *
  4. * 复现:旧实现把临时文件建在 os.tmpdir()(本环境=C 盘),书仓在本仓 D 盘——
  5. * renameSync 跨盘抛 EXDEV。修复后临时文件与目标同目录,同盘 rename 原子生效。
  6. * 书仓刻意建在 process.cwd() 下(与 C 盘临时目录异盘)以保持复现条件。
  7. */
  8. import { afterAll, describe, expect, it } from 'vitest'
  9. import * as fs from 'node:fs'
  10. import * as nodePath from 'node:path'
  11. import { writeBatchAtomic, writeFileAtomic } from '../src/index'
  12. import { removeSync } from '../src/repo/remove'
  13. const fixtureParent = nodePath.join(process.cwd(), '.tmp')
  14. fs.mkdirSync(fixtureParent, { recursive: true })
  15. const bookRoot = fs.mkdtempSync(nodePath.join(fixtureParent, 'f6-cross-drive-book-'))
  16. const tmpDrive = nodePath.parse(require('node:os').tmpdir()).root
  17. const bookDrive = nodePath.parse(process.cwd()).root
  18. const crossDrive = tmpDrive.toLowerCase() !== bookDrive.toLowerCase()
  19. afterAll(() => { removeSync(bookRoot) })
  20. describe('F6:跨盘原子写入', () => {
  21. it('环境前提:书仓与系统临时目录异盘(同盘环境本例退化为普通验证)', () => {
  22. expect(crossDrive || tmpDrive === bookDrive).toBe(true)
  23. })
  24. it('单文件与批量写入成功(跨盘亦然),内容完整', () => {
  25. const rel = '定稿/卷01/0001-开篇任务.md'
  26. writeFileAtomic(bookRoot, rel, '跨盘原子写入正文。\n\n第二段。')
  27. expect(fs.readFileSync(nodePath.join(bookRoot, rel), 'utf-8')).toContain('跨盘原子写入正文')
  28. writeBatchAtomic(bookRoot, [
  29. { relPath: '大纲/卷规划/卷01/章细纲/0001-开篇任务.md', content: '# 章细纲\n' },
  30. { relPath: '草稿区/草稿/卷01-开篇任务/稿1.md', content: '草稿正文' },
  31. ])
  32. expect(fs.existsSync(nodePath.join(bookRoot, '草稿区/草稿/卷01-开篇任务/稿1.md'))).toBe(true)
  33. })
  34. it('写入后目录零临时残留', () => {
  35. const dir = nodePath.join(bookRoot, '定稿/卷01')
  36. const residue = fs.readdirSync(dir).filter((f) => f.includes('.tmp') || f.startsWith('.webnovel-b1'))
  37. expect(residue).toEqual([])
  38. })
  39. it('批量中途失败 → 完全回滚,零残留,原目标不被半写覆盖', () => {
  40. const target = nodePath.join(bookRoot, '定稿/卷01/0001-开篇任务.md')
  41. const before = fs.readFileSync(target, 'utf-8')
  42. // 第二个 op 的父目录与既有文件同名 → staging 中途 mkdir 失败 → 触发回滚
  43. expect(() => writeBatchAtomic(bookRoot, [
  44. { relPath: '定稿/卷01/0002-第二章.md', content: '第二章' },
  45. { relPath: '定稿/卷01/0001-开篇任务.md/子.md', content: 'x' },
  46. ])).toThrow(/原子写失败/)
  47. // 第一个 op 已写的 0002 须回滚干净;原目标不受影响
  48. expect(fs.existsSync(nodePath.join(bookRoot, '定稿/卷01/0002-第二章.md'))).toBe(false)
  49. expect(fs.readFileSync(target, 'utf-8')).toBe(before)
  50. const residue = fs.readdirSync(nodePath.join(bookRoot, '定稿/卷01')).filter((f) => f.startsWith('.webnovel-b1') || f.endsWith('.tmp'))
  51. expect(residue).toEqual([])
  52. })
  53. })