user-text.client.spec.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. // @vitest-environment jsdom
  2. /**
  3. * Inline projection of sent user text: decoration never breaks a single-line
  4. * message (bubble regression), and wire session forms fold to their label
  5. * (queue-row readability).
  6. */
  7. import { describe, expect, it, vi } from 'vitest'
  8. import { fireEvent, render } from '@testing-library/react'
  9. import { projectUserText } from '../src/user-text.tsx'
  10. const project = (
  11. text: string,
  12. labels: readonly string[] = [],
  13. slashNames: readonly string[] = [],
  14. slashKind: 'skill' | 'command' = 'skill',
  15. ) =>
  16. render(<div data-host>{projectUserText(text, labels, slashNames, slashKind)}</div>).container.querySelector('[data-host]')!
  17. describe('projectUserText', () => {
  18. it('keeps a decorated single-line message on one line: every part is inline', () => {
  19. const host = project('反反复复 /dsh-acp-test @执行几个命令测试', ['执行几个命令测试'], ['dsh-acp-test'])
  20. expect(host.querySelectorAll('div').length).toBe(0)
  21. expect(host.textContent).toBe('反反复复 /dsh-acp-test 执行几个命令测试')
  22. const chips = host.querySelectorAll('[data-ref-chip]')
  23. expect([...chips].map(c => c.getAttribute('data-ref-chip'))).toEqual(['skill', 'session'])
  24. // The whitespace between tokens survives as its own inline run.
  25. const runs = [...host.querySelectorAll('span')].filter(s => !s.hasAttribute('data-ref-chip') && s.closest('[data-ref-chip]') === null)
  26. expect(runs.map(r => r.textContent)).toEqual(['反反复复 ', ' '])
  27. })
  28. it('folds the wire session form to its label with the session glyph', () => {
  29. const host = project('看看 @[查看并分析图片](dsh-session:InNlc3Npb24tNDM0) 的结论')
  30. const chip = host.querySelector('[data-ref-chip="session"]')!
  31. expect(chip.textContent).toBe('查看并分析图片')
  32. expect(chip.getAttribute('title')).toBe('@[查看并分析图片](dsh-session:InNlc3Npb24tNDM0)')
  33. expect(chip.querySelector('svg')).not.toBeNull()
  34. expect(host.textContent).toBe('看看 查看并分析图片 的结论')
  35. })
  36. it('prefers the wire fold over the bare-token scan on the same range', () => {
  37. const host = project('@[a](dsh-session:x)', [])
  38. expect(host.querySelectorAll('[data-ref-chip]').length).toBe(1)
  39. expect(host.querySelector('[data-ref-chip="session"]')!.textContent).toBe('a')
  40. })
  41. it('decorates recall-associated labels, files, folders, and quoted paths', () => {
  42. const host = project('@会话一 说 @src/deep/file.txt 与 @dir/ 与 @"a b.md"', ['会话一'])
  43. const kinds = [...host.querySelectorAll('[data-ref-chip]')].map(c =>
  44. [c.getAttribute('data-ref-chip'), c.textContent])
  45. expect(kinds).toEqual([
  46. ['session', '会话一'],
  47. ['file', 'file.txt'],
  48. ['folder', 'dir'],
  49. ['file', 'a b.md'],
  50. ])
  51. })
  52. it('repeated recall labels decorate every occurrence once', () => {
  53. const host = project('@再看 前情 @再看', ['再看', '再看'])
  54. expect(host.querySelectorAll('[data-ref-chip="session"]').length).toBe(2)
  55. })
  56. it('keeps a punctuation-glued slash token plain and skips degenerate tokens', () => {
  57. // The host skill gesture ends at whitespace or the text end, so `/plan。`
  58. // never loads a skill; the bubble must not suggest otherwise.
  59. const host = project('用 /plan。 试试 @。', [], ['plan'])
  60. expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)
  61. expect(host.textContent).toBe('用 /plan。 试试 @。')
  62. })
  63. it('decorates a slash token only when the host resolved it as a skill in that step', () => {
  64. const bare = project('/123')
  65. expect(bare.querySelectorAll('[data-ref-chip]').length).toBe(0)
  66. expect(bare.textContent).toBe('/123')
  67. const unresolved = project('用 /plan 看看')
  68. expect(unresolved.querySelectorAll('[data-ref-chip]').length).toBe(0)
  69. const resolved = project('用 /plan 看看', [], ['plan'])
  70. expect([...resolved.querySelectorAll('[data-ref-chip]')].map(c => [c.getAttribute('data-ref-chip'), c.textContent]))
  71. .toEqual([['skill', '/plan']])
  72. })
  73. it('marks a resolved slash token as a command chip when the caller says so', () => {
  74. const host = project('/goal ship it\nsecond line', [], ['goal'], 'command')
  75. const chips = [...host.querySelectorAll('[data-ref-chip]')]
  76. expect(chips.map(c => [c.getAttribute('data-ref-chip'), c.textContent])).toEqual([['command', '/goal']])
  77. expect(host.textContent).toBe('/goal ship it\nsecond line')
  78. })
  79. it('leaves slash paths undecorated even for a resolved name: a /name token ends at whitespace', () => {
  80. const text = '测试一下ui,不用管我:\n/nfs-hg/xxx/yyy 与 /root-dir/ 和 /plan.md'
  81. const host = project(text, [], ['nfs-hg', 'root-dir', 'plan'])
  82. expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)
  83. expect(host.textContent).toBe(text)
  84. })
  85. it('prefers the longer recall label when one nests inside another', () => {
  86. const host = project('@会话一 收尾', ['会话', '会话一'])
  87. const chips = [...host.querySelectorAll('[data-ref-chip="session"]')]
  88. expect(chips.map(c => c.textContent)).toEqual(['会话一'])
  89. expect(host.textContent).toBe('会话一 收尾')
  90. })
  91. it('falls back to the raw quoted label when the path has no basename', () => {
  92. const host = project('看 @"/" 下面')
  93. const chip = host.querySelector('[data-ref-chip="folder"]')!
  94. expect(chip.textContent).toBe('"/"')
  95. })
  96. it('opens decoded files and loaded skills without activating session, folder, or command references', () => {
  97. const openFile = vi.fn()
  98. const openSkill = vi.fn()
  99. const view = render(<div>{projectUserText(
  100. '@src/a.ts @"notes a.md" /review @history @dir/ @"dir a/"', ['history'], ['review'], 'skill',
  101. { openFile, openSkill },
  102. )}</div>)
  103. fireEvent.click(view.getByRole('button', { name: 'a.ts' }))
  104. fireEvent.click(view.getByRole('button', { name: 'notes a.md' }))
  105. fireEvent.click(view.getByRole('button', { name: '/review' }))
  106. expect(openFile.mock.calls).toEqual([['src/a.ts'], ['notes a.md']])
  107. expect(openSkill).toHaveBeenCalledWith('review')
  108. expect(view.container.querySelectorAll('button')).toHaveLength(3)
  109. const command = render(<div>{projectUserText('/help', [], ['help'], 'command', { openFile, openSkill })}</div>)
  110. expect(command.container.querySelector('button')).toBeNull()
  111. })
  112. it('preserves text-selection gestures and keyboard activation', () => {
  113. const openFile = vi.fn()
  114. const view = render(<div>{projectUserText('@notes.md', [], [], 'skill', { openFile, openSkill: vi.fn() })}</div>)
  115. const button = view.getByRole('button', { name: 'notes.md' })
  116. const selection = document.getSelection()!
  117. const range = document.createRange()
  118. range.selectNodeContents(button)
  119. selection.addRange(range)
  120. fireEvent.click(button, { detail: 1 })
  121. expect(openFile).not.toHaveBeenCalled()
  122. fireEvent.click(button, { detail: 0 })
  123. expect(openFile).toHaveBeenCalledWith('notes.md')
  124. selection.removeAllRanges()
  125. openFile.mockClear()
  126. fireEvent.click(button, { detail: 2 })
  127. expect(openFile).not.toHaveBeenCalled()
  128. })
  129. it('renders undecorated text as one inline run', () => {
  130. const host = project('纯文本,无引用')
  131. expect(host.querySelectorAll('div').length).toBe(0)
  132. expect(host.querySelectorAll('[data-ref-chip]').length).toBe(0)
  133. expect(host.textContent).toBe('纯文本,无引用')
  134. })
  135. })