context-meter.client.spec.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it } from 'vitest'
  3. import { cleanup, fireEvent, render } from '@testing-library/react'
  4. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  5. import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
  6. import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
  7. import { contextOccupancy } from '../src/client/context-occupancy.ts'
  8. import css from '../src/client/skeleton/ContextMeter.module.css'
  9. import { en, zh } from '../src/client/locales.ts'
  10. afterEach(cleanup)
  11. const t = makeTranslate(zh, commonZh) as ContextMeterProps['t']
  12. const tEn = makeTranslate(en, commonEn) as ContextMeterProps['t']
  13. const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_000 }
  14. const segmentClass = css.segment
  15. if (segmentClass === undefined) throw new Error('segment class missing from ContextMeter.module.css')
  16. function projections(values: Record<string, unknown>): ContextMeterProps['useProjection'] {
  17. return (key: string) => values[key]
  18. }
  19. function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'] = t) {
  20. return render(<ContextMeter useProjection={projections(values)} t={translate} />)
  21. }
  22. describe('ContextMeter', () => {
  23. it('computes occupancy only when both a numerator and capacity are known', () => {
  24. expect(contextOccupancy({ pressureTokens: 32_000, projectedTokens: 6_000, contextWindow: 128_000 }))
  25. .toEqual({ percent: 5, usedTokens: 6_000, contextWindow: 128_000 })
  26. expect(contextOccupancy({ pressureTokens: 32_000, contextWindow: 128_000 }))
  27. .toEqual({ percent: 25, usedTokens: 32_000, contextWindow: 128_000 })
  28. expect(contextOccupancy({ pressureTokens: 32_000 })).toBeNull()
  29. expect(contextOccupancy({ contextWindow: 128_000 })).toBeNull()
  30. expect(contextOccupancy(undefined)).toBeNull()
  31. expect(contextOccupancy({ pressureTokens: 300_000, contextWindow: 128_000 })?.percent).toBe(100)
  32. })
  33. it('renders nothing until both pressure and capacity are known', () => {
  34. expect(meter({}).container.textContent).toBe('')
  35. expect(meter({ contextPressure: { pressureTokens: 32_000 } }).container.textContent).toBe('')
  36. expect(meter({ contextPressure: { contextWindow: 128_000 } }).container.textContent).toBe('')
  37. })
  38. it('shows the occupancy ring and opens the breakdown panel on click', () => {
  39. const view = meter({
  40. contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
  41. contextBreakdown: BREAKDOWN,
  42. })
  43. const trigger = view.getByRole('button', { name: '上下文已用 25%' })
  44. expect(view.queryByRole('dialog')).toBeNull()
  45. fireEvent.click(trigger)
  46. const panel = view.queryByRole('dialog')!
  47. expect(panel.textContent).toContain('~32K / 128K')
  48. expect(panel.textContent).toContain('25%')
  49. expect(panel.textContent).toContain('上下文已用')
  50. expect(panel.textContent).toContain('系统提示词~120')
  51. expect(panel.textContent).toContain('工具定义~21.5K')
  52. expect(panel.textContent).toContain('对话消息~477K')
  53. // The occupancy bar splits into one colored segment per composition row.
  54. expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3)
  55. // Clicking the trigger again toggles the panel shut.
  56. fireEvent.click(trigger)
  57. expect(view.queryByRole('dialog')).toBeNull()
  58. })
  59. it('lets each locale own the headline word order around the reading', () => {
  60. const values = {
  61. contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
  62. contextBreakdown: BREAKDOWN,
  63. }
  64. const zhView = meter(values)
  65. fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' }))
  66. // The reading follows the label in Chinese and leads it in English; both
  67. // headers read as one sentence rather than a concatenated fragment.
  68. expect(zhView.queryByRole('dialog')!.textContent)
  69. .toMatch(/^上下文已用25%/)
  70. const enView = meter(values, tEn)
  71. fireEvent.click(enView.getByRole('button', { name: '25% of context used' }))
  72. expect(enView.queryByRole('dialog', { name: 'of context used' })!.textContent)
  73. .toMatch(/^25%of context used/)
  74. })
  75. it('draws no bar segment at zero occupancy', () => {
  76. const view = meter({
  77. contextPressure: { pressureTokens: 0, contextWindow: 128_000 },
  78. contextBreakdown: BREAKDOWN,
  79. })
  80. fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' }))
  81. const panel = view.queryByRole('dialog')!
  82. // `.segment` carries a min-width, so a zero-width part would still paint a
  83. // filled sliver over an empty context.
  84. expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
  85. expect(panel.textContent).toContain('~0 / 128K')
  86. })
  87. it('reads the ring from the projected figure so a compaction shows at once', () => {
  88. // Same provider sample, a surface a compaction just shrank: the ring must
  89. // follow the projection rather than the sample it is anchored to.
  90. const view = meter({
  91. contextPressure: { pressureTokens: 32_000, projectedTokens: 3_000, contextWindow: 128_000 },
  92. contextBreakdown: BREAKDOWN,
  93. })
  94. const trigger = view.getByRole('button', { name: '上下文已用 2%' })
  95. fireEvent.click(trigger)
  96. expect(view.queryByRole('dialog')!.textContent).toContain('~3K / 128K')
  97. })
  98. it('omits the composition rows while the contextBreakdown projection is absent', () => {
  99. const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
  100. fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
  101. const panel = view.queryByRole('dialog')!
  102. expect(panel.textContent).toContain('~32K / 128K')
  103. expect(panel.textContent).not.toContain('系统提示词')
  104. expect(panel.textContent).not.toContain('对话消息')
  105. // Without composition shares, the bar falls back to one plain segment.
  106. expect(panel.getElementsByClassName(segmentClass)).toHaveLength(1)
  107. })
  108. it('closes when capacity disappears and stays closed when it returns', () => {
  109. let values: Record<string, unknown> = {
  110. contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
  111. contextBreakdown: BREAKDOWN,
  112. }
  113. const view = render(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
  114. fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
  115. expect(view.queryByRole('dialog')).not.toBeNull()
  116. values = { contextPressure: { pressureTokens: 32_000 }, contextBreakdown: BREAKDOWN }
  117. view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
  118. expect(view.container.textContent).toBe('')
  119. values = {
  120. contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
  121. contextBreakdown: BREAKDOWN,
  122. }
  123. view.rerender(<ContextMeter useProjection={(key: string) => values[key]} t={t} />)
  124. expect(view.getByRole('button', { name: '上下文已用 25%' }).getAttribute('aria-expanded')).toBe('false')
  125. expect(view.queryByRole('dialog')).toBeNull()
  126. })
  127. it('closes on outside pointerdown and Escape — but not inside clicks', () => {
  128. const view = meter({
  129. contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
  130. contextBreakdown: BREAKDOWN,
  131. })
  132. const trigger = view.getByRole('button', { name: '上下文已用 25%' })
  133. const openPanel = () => {
  134. fireEvent.click(trigger)
  135. return view.queryByRole('dialog')!
  136. }
  137. // A pointerdown inside the panel keeps it open; outside closes it.
  138. const again = openPanel()
  139. fireEvent.pointerDown(again)
  140. expect(view.queryByRole('dialog')).not.toBeNull()
  141. fireEvent.pointerDown(document.body)
  142. expect(view.queryByRole('dialog')).toBeNull()
  143. // Escape.
  144. openPanel()
  145. fireEvent.keyDown(document, { key: 'Escape' })
  146. expect(view.queryByRole('dialog')).toBeNull()
  147. })
  148. })