context-meter.spec.tsx 7.5 KB

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