study-client.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { createEditorStore, fileKey } from '../src/client/store'
  3. import { callStudy, StudyApiError } from '../src/client/api'
  4. import { documentQuote } from '../src/client/quote'
  5. import { parseStudyLink, studyLink } from '../src/study/links'
  6. import type { StudyDocument, StudySave } from '../src/study/types'
  7. vi.mock('../src/client/api', async importOriginal => ({ ...await importOriginal<typeof import('../src/client/api')>(), callStudy: vi.fn() }))
  8. const request = vi.mocked(callStudy)
  9. const doc = (name = 'a'): StudyDocument => ({ ref: { space: 'book:one', path: `大纲/${name}.md` }, owner: '作品', name: name + '.md', absolutePath: '/workspace/作品/大纲/' + name + '.md', body: '原文\n', hash: 'old-hash', version: 1 })
  10. const result = (document: StudyDocument): StudySave => ({ operationId: 'test-operation-id', document, previousPath: document.ref.path, changed: true, changes: [], commit: 'saved', notification: 'delivered', route: '核对设计' })
  11. function deferred<T>() { let resolve!: (value: T) => void; const promise = new Promise<T>(done => { resolve = done }); return { promise, resolve } }
  12. beforeEach(() => { request.mockReset(); vi.stubGlobal('window', { confirm: vi.fn(() => true) }) })
  13. afterEach(() => vi.unstubAllGlobals())
  14. describe('编辑器会话与保存竞态', () => {
  15. it('打开成功才通知原生侧栏,每次打开均可重新显示且保留未保存缓冲', async () => {
  16. const reveal = vi.fn(), store = createEditorStore(reveal)
  17. request.mockResolvedValueOnce(doc())
  18. await store.open('one', doc().ref)
  19. const key = fileKey(doc().ref)
  20. store.updateBuffer('one', key, { text: '未保存内容' })
  21. store.cancelOpen('one')
  22. await store.open('one', doc().ref)
  23. expect(reveal.mock.calls).toEqual([['one'], ['one']])
  24. expect(request).toHaveBeenCalledTimes(1)
  25. expect(store.get('one').buffers[key]?.text).toBe('未保存内容')
  26. })
  27. it('关闭期间取消未完成的打开,不让迟到结果重新展开侧栏', async () => {
  28. const reveal = vi.fn(), store = createEditorStore(reveal), pending = deferred<StudyDocument>()
  29. request.mockReturnValueOnce(pending.promise)
  30. const opening = store.open('one', doc().ref)
  31. store.cancelOpen('one'); pending.resolve(doc()); await opening
  32. expect(reveal).not.toHaveBeenCalled()
  33. expect(store.get('one').buffers).toEqual({})
  34. })
  35. it('后点文档优先,迟到的旧打开响应不能抢焦点', async () => {
  36. const store = createEditorStore(), old = deferred<StudyDocument>(), latest = deferred<StudyDocument>()
  37. request.mockReturnValueOnce(old.promise).mockReturnValueOnce(latest.promise)
  38. const first = store.open('one', doc('a').ref), second = store.open('one', doc('b').ref)
  39. latest.resolve(doc('b')); await second
  40. old.resolve(doc('a')); await first
  41. expect(store.get('one').current).toBe(fileKey(doc('b').ref))
  42. })
  43. it('旧会话返回只更新旧缓冲区,不污染另一会话', async () => {
  44. const store = createEditorStore(), pending = deferred<StudyDocument>()
  45. request.mockReturnValueOnce(pending.promise).mockResolvedValueOnce(doc('b'))
  46. const first = store.open('one', doc().ref)
  47. await store.open('two', doc('b').ref)
  48. pending.resolve(doc()); await first
  49. expect(store.get('two').current).toBe(fileKey(doc('b').ref))
  50. expect(Object.keys(store.get('two').buffers)).toHaveLength(1)
  51. })
  52. it('保存期间新增的编辑不会被保存响应覆盖', async () => {
  53. const store = createEditorStore(), saved = deferred<StudySave>()
  54. request.mockResolvedValueOnce(doc()).mockReturnValueOnce(saved.promise)
  55. await store.open('one', doc().ref)
  56. const key = fileKey(doc().ref)
  57. store.updateBuffer('one', key, { text: '提交的修改' })
  58. const saving = store.save('one', key)
  59. store.updateBuffer('one', key, { text: '继续输入的修改' })
  60. saved.resolve(result({ ...doc(), body: '提交的修改\n', hash: 'new-hash' })); await saving
  61. expect(store.get('one').buffers[key]?.text).toBe('继续输入的修改')
  62. expect(store.get('one').buffers[key]?.document.body).toBe('提交的修改\n')
  63. })
  64. it('不确定的失败保留原编号重试,再保留其后的编辑', async () => {
  65. const store = createEditorStore()
  66. request.mockResolvedValueOnce(doc()).mockRejectedValueOnce(new Error('connection lost')).mockResolvedValueOnce(result({ ...doc(), body: '第一次修改\n', hash: 'new' }))
  67. await store.open('one', doc().ref)
  68. const key = fileKey(doc().ref)
  69. store.updateBuffer('one', key, { text: '第一次修改' })
  70. await store.save('one', key)
  71. const attempt = store.get('one').buffers[key]?.attempt
  72. expect(attempt).toBeDefined()
  73. store.updateBuffer('one', key, { text: '随后修改' })
  74. await store.save('one', key)
  75. expect(request.mock.calls[2]?.[2]).toEqual(attempt)
  76. expect(store.get('one').buffers[key]?.text).toBe('随后修改')
  77. })
  78. it('版本冲突保留编辑,磁盘比较不会自动覆盖', async () => {
  79. const store = createEditorStore()
  80. request.mockResolvedValueOnce(doc()).mockRejectedValueOnce(new StudyApiError('conflict', '版本变化')).mockResolvedValueOnce({ ...doc(), body: '磁盘新版', hash: 'new' })
  81. await store.open('one', doc().ref)
  82. const key = fileKey(doc().ref)
  83. store.updateBuffer('one', key, { text: '我的编辑' })
  84. await store.save('one', key); await store.compare('one', key)
  85. expect(store.get('one').buffers[key]?.text).toBe('我的编辑')
  86. expect(store.get('one').buffers[key]?.document.hash).toBe('old-hash')
  87. store.useDisk('one', key, true)
  88. expect(store.get('one').buffers[key]?.document.hash).toBe('new')
  89. expect(store.get('one').buffers[key]?.text).toBe('我的编辑')
  90. })
  91. it('关闭脏文档可以取消,卸载后的请求不写回状态', async () => {
  92. const store = createEditorStore()
  93. request.mockResolvedValueOnce(doc())
  94. await store.open('one', doc().ref)
  95. const key = fileKey(doc().ref)
  96. store.updateBuffer('one', key, { text: '未保存' })
  97. vi.mocked(window.confirm).mockReturnValue(false)
  98. expect(store.close('one', key)).toBe(false)
  99. expect(store.hasUnsaved()).toBe(true)
  100. store.dispose()
  101. expect(store.hasUnsaved()).toBe(false)
  102. })
  103. })
  104. describe('完整原文引用', () => {
  105. it('文档链接结构化往返中文路径,拒绝外站和非法载荷', () => {
  106. const ref = { space: 'book:one', path: '草稿区/稿1.md' }
  107. const link = studyLink('http://127.0.0.1:6094', 'one', ref)
  108. expect(parseStudyLink(link, 'http://127.0.0.1:6094')).toEqual({ sessionId: 'one', ref })
  109. expect(parseStudyLink(link, 'http://evil.example')).toBeUndefined()
  110. expect(parseStudyLink('http://127.0.0.1:6094/?webnovel=bad', 'http://127.0.0.1:6094')).toBeUndefined()
  111. })
  112. it('长中文引用保留全文和来源,不因超过 500 字符退化成路径', () => {
  113. const text = '中文段落。'.repeat(200)
  114. const quote = documentQuote({ ...doc(), body: text }, text, text)
  115. expect(quote).toContain(text)
  116. expect(quote).toContain('book:one')
  117. expect(quote).toContain('old-hash')
  118. expect(quote).toContain('正文第 1 行')
  119. })
  120. it('重复段落不猜位置,未保存引用明确标注基线', () => {
  121. const quote = documentQuote(doc(), '重复。\n重复。', '重复。')
  122. expect(quote).toContain('未猜测位置')
  123. expect(quote).toContain('未保存编辑')
  124. expect(quote).not.toContain('正文第')
  125. })
  126. })