components.client.spec.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
  4. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
  5. import type { GeneralSectionComponentProps } from '../src/client/GeneralSection.tsx'
  6. import { GeneralSection } from '../src/client/GeneralSection.tsx'
  7. import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
  8. import type { TriggerContentProps } from '../src/client/chrome.tsx'
  9. import { SettingsDocumentAction } from '../src/client/SettingsDocumentAction.tsx'
  10. import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
  11. import { SettingsDocumentStore } from '../src/client/settings-document-store.ts'
  12. /** Store over a real mirror derived from the same fake wire. */
  13. function derivedDocumentStore(api: object) {
  14. const wire = api as never
  15. return new SettingsDocumentStore(wire, new SettingsDescribeMirror(wire))
  16. }
  17. import { en } from '../src/client/locales.ts'
  18. afterEach(cleanup)
  19. // The seat's key domain is settings ∪ common; the stub answers from the
  20. // package dictionary and falls back to the key like the real chain.
  21. const t: TriggerContentProps['t'] = key => (en as Record<string, string>)[key] ?? key
  22. // Global standard kit stubs: none of these components consume the hooks.
  23. const unusedHook = (() => { throw new Error('unused by settings-general components') }) as never
  24. type AttentionSnapshot = Parameters<Parameters<TriggerContentProps['useSessionPendingInteraction']>[0]>[0]
  25. const noAttention: AttentionSnapshot = new Map()
  26. const useSessionPendingInteraction: TriggerContentProps['useSessionPendingInteraction'] = selector => selector(noAttention)
  27. const kit = { useSessions: unusedHook, useSessionPendingInteraction, useWorkspaces: unusedHook }
  28. describe('chrome content', () => {
  29. it('TriggerContent renders the icon with the label in the wide column', () => {
  30. const { container } = render(<TriggerContent {...kit} wide t={t} />)
  31. expect(container.querySelector('svg')).toBeTruthy()
  32. expect(screen.getByText('Settings')).toBeTruthy()
  33. })
  34. it('TriggerContent drops the label in the rail state', () => {
  35. const { container } = render(<TriggerContent {...kit} wide={false} t={t} />)
  36. expect(container.querySelector('svg')).toBeTruthy()
  37. expect(screen.queryByText('Settings')).toBeNull()
  38. })
  39. it('HeaderContent and CloseLabel render their translated text', () => {
  40. render(<HeaderContent {...kit} t={t} />)
  41. render(<CloseLabel {...kit} t={t} />)
  42. expect(screen.getByText('Settings')).toBeTruthy()
  43. expect(screen.getByText('Close')).toBeTruthy()
  44. })
  45. })
  46. describe('GeneralSection', () => {
  47. function mount() {
  48. const renderSlot = vi.fn(
  49. ((key: string) => <div data-testid={`slot-${key}`} />) as GeneralSectionComponentProps['renderSlot'],
  50. )
  51. const props: GeneralSectionComponentProps = { ...kit, renderSlot, close: vi.fn() }
  52. const view = render(<GeneralSection {...props} />)
  53. return { view, renderSlot }
  54. }
  55. it('renders the item slot as the section body', () => {
  56. const { renderSlot } = mount()
  57. expect(renderSlot).toHaveBeenCalledWith('settings.general.item', {})
  58. expect(screen.getByTestId('slot-settings.general.item')).toBeTruthy()
  59. })
  60. })
  61. describe('SettingsDocumentAction', () => {
  62. it('appears only for a file-backed provider and requests its Host-owned document', async () => {
  63. const openDocument = vi.fn(() => Promise.resolve({
  64. rpcId: 'document-open' as never,
  65. result: { ok: true as const, value: { opened: true as const } },
  66. }))
  67. const controller = derivedDocumentStore({
  68. settings: {
  69. describe: vi.fn(() => Promise.resolve({
  70. rpcId: 'document-action' as never,
  71. result: {
  72. ok: true as const,
  73. value: { writable: true, hasDocument: true, namespaces: [] },
  74. },
  75. })),
  76. openDocument,
  77. },
  78. })
  79. render(<SettingsDocumentAction
  80. {...kit}
  81. t={t}
  82. controller={controller}
  83. useSnapshot={bindSnapshotSelector(controller.store)}
  84. />)
  85. const action = await screen.findByRole('button', { name: 'Open configuration file' })
  86. fireEvent.click(action)
  87. await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) })
  88. })
  89. it('stays absent without a document and follows a mirror refresh to available', async () => {
  90. const describe = vi.fn()
  91. .mockResolvedValueOnce({
  92. rpcId: 'document-action-absent' as never,
  93. result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
  94. })
  95. .mockResolvedValueOnce({
  96. rpcId: 'document-action-ready' as never,
  97. result: { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } },
  98. })
  99. const wire = { settings: { describe, openDocument: vi.fn() } } as never
  100. const mirror = new SettingsDescribeMirror(wire)
  101. const controller = new SettingsDocumentStore(wire, mirror)
  102. const first = render(<SettingsDocumentAction
  103. {...kit}
  104. t={t}
  105. controller={controller}
  106. useSnapshot={bindSnapshotSelector(controller.store)}
  107. />)
  108. await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
  109. expect(screen.queryByRole('button', { name: 'Open configuration file' })).toBeNull()
  110. first.unmount()
  111. render(<SettingsDocumentAction
  112. {...kit}
  113. t={t}
  114. controller={controller}
  115. useSnapshot={bindSnapshotSelector(controller.store)}
  116. />)
  117. // A remount alone re-reads nothing; availability moves with the mirror's
  118. // own refresh (a document commit or reconnect in production).
  119. await waitFor(() => { expect(controller.store.getSnapshot().status).toBe('unavailable') })
  120. expect(describe).toHaveBeenCalledTimes(1)
  121. await mirror.load()
  122. expect(await screen.findByRole('button', { name: 'Open configuration file' })).toBeTruthy()
  123. expect(describe).toHaveBeenCalledTimes(2)
  124. })
  125. it('keeps the action available and reports a native-open failure', async () => {
  126. const controller = derivedDocumentStore({
  127. settings: {
  128. describe: vi.fn(() => Promise.resolve({
  129. rpcId: 'document-action' as never,
  130. result: {
  131. ok: true as const,
  132. value: { writable: true, hasDocument: true, namespaces: [] },
  133. },
  134. })),
  135. openDocument: vi.fn(() => Promise.resolve({
  136. rpcId: 'document-open-failed' as never,
  137. result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } },
  138. })),
  139. },
  140. })
  141. render(<SettingsDocumentAction
  142. {...kit}
  143. t={t}
  144. controller={controller}
  145. useSnapshot={bindSnapshotSelector(controller.store)}
  146. />)
  147. fireEvent.click(await screen.findByRole('button', { name: 'Open configuration file' }))
  148. expect((await screen.findByRole('alert')).textContent).toBe('Could not open configuration file')
  149. expect(screen.getByRole('button', { name: 'Open configuration file' })).toBeTruthy()
  150. })
  151. })