section.client.spec.tsx 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. // @vitest-environment jsdom
  2. import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
  5. import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
  6. import { SubagentCard, type SubagentCardProps } from '../src/client/SubagentCard.tsx'
  7. import type { SubagentLimitsCardState } from '../src/client/subagent-limits-card-controller.ts'
  8. import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx'
  9. import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx'
  10. import { BashCard } from '../src/client/BashCard.tsx'
  11. import type { BashCardProps } from '../src/client/BashCard.tsx'
  12. import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
  13. import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
  14. import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
  15. import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
  16. import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
  17. import type { BashCardState } from '../src/client/bash-card-controller.ts'
  18. import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
  19. import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
  20. import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts'
  21. import { en } from '../src/client/locales.ts'
  22. afterEach(cleanup)
  23. const t = (key: keyof typeof en) => en[key]
  24. const settled: CardShell = {
  25. available: true,
  26. writable: true,
  27. dirty: false,
  28. invalid: false,
  29. saving: false,
  30. failed: false,
  31. }
  32. function field(text: string, rest: Partial<CardFieldState> = {}): CardFieldState {
  33. return { text, overridden: false, invalid: false, ...rest }
  34. }
  35. function cardActions() {
  36. return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() }
  37. }
  38. function renderSection(rows: readonly PluginsSettingsTabEntry[]) {
  39. const props = {
  40. t,
  41. useTabs: (selector: (value: readonly PluginsSettingsTabEntry[]) => unknown) => selector(rows),
  42. renderSlot: (_name: string, _owner: unknown, options: { only?: string }) => (
  43. <span>{options.only}</span>
  44. ),
  45. } as unknown as PluginsSettingsSectionProps
  46. render(<PluginsSettingsSection {...props} />)
  47. }
  48. /** The Plugins page asks a configuration entry for its one-liner or its form; every page renders as `page` unless a test says otherwise. */
  49. type ConfigView = 'summary' | 'page'
  50. function renderBashCard(state: Partial<BashCardState> = {}, view: ConfigView = 'page') {
  51. const store = createSnapshotStore<BashCardState>({
  52. ...settled,
  53. timeoutMs: field('60000'),
  54. maxOutputBytes: field('64000'),
  55. ...state,
  56. })
  57. const actions = cardActions()
  58. const props = { ...actions, view, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps
  59. render(<BashCard {...props} />)
  60. return { actions, store }
  61. }
  62. function renderBash(state: Partial<BashCardState> = {}) {
  63. return renderBashCard(state).actions
  64. }
  65. function renderSubagent(
  66. limitState: Partial<SubagentLimitsCardState> = {},
  67. modelState: Partial<SubagentModelSelectionCardState> = {},
  68. view: ConfigView = 'page',
  69. ) {
  70. const limits = createSnapshotStore<SubagentLimitsCardState>({
  71. ...settled,
  72. maxDepth: field('3'),
  73. maxActiveSubagents: field('8'),
  74. ...limitState,
  75. })
  76. const models = createSnapshotStore<SubagentModelSelectionCardState>({
  77. ...settled,
  78. enabled: false,
  79. candidates: [],
  80. catalogStatus: 'idle',
  81. catalogPartial: false,
  82. conflicted: false,
  83. ...modelState,
  84. })
  85. const actions = {
  86. editLimit: vi.fn(),
  87. resetLimit: vi.fn(),
  88. toggleEnabled: vi.fn(),
  89. toggleModel: vi.fn(),
  90. retryCatalog: vi.fn(),
  91. save: vi.fn(),
  92. discard: vi.fn(),
  93. }
  94. const props = {
  95. ...actions,
  96. view,
  97. t,
  98. useSubagentLimitsCard: bindSnapshotSelector(limits),
  99. useSubagentModelSelectionCard: bindSnapshotSelector(models),
  100. } as unknown as SubagentCardProps
  101. render(<SubagentCard {...props} />)
  102. return { actions, limits, models }
  103. }
  104. function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}, view: ConfigView = 'page') {
  105. return renderSubagent({ available: false }, state, view).actions
  106. }
  107. describe('PluginsSettingsSection', () => {
  108. it('says so when no plugin contributed a tab', () => {
  109. renderSection([])
  110. expect(screen.getByText(en.empty)).toBeTruthy()
  111. expect(screen.queryByRole('tab')).toBeNull()
  112. })
  113. it('defaults to the first ordered tab and mounts another only after selection', () => {
  114. renderSection([
  115. { id: 'configurable', order: 0, label: 'Configurable' },
  116. { id: 'all', order: 10, label: 'Plugin list' },
  117. ])
  118. const configurable = screen.getByRole('tab', { name: 'Configurable' })
  119. const all = screen.getByRole('tab', { name: 'Plugin list' })
  120. expect(configurable.getAttribute('aria-selected')).toBe('true')
  121. expect(screen.getByText('configurable')).toBeTruthy()
  122. expect(screen.queryByText('all')).toBeNull()
  123. fireEvent.click(all)
  124. expect(all.getAttribute('aria-selected')).toBe('true')
  125. expect(screen.getByText('all')).toBeTruthy()
  126. expect(screen.getByText('configurable').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
  127. fireEvent.click(configurable)
  128. expect(configurable.getAttribute('aria-selected')).toBe('true')
  129. expect(screen.getByText('all').closest('[role="tabpanel"]')).toHaveProperty('hidden', true)
  130. })
  131. it('leads with its own heading and intro', () => {
  132. renderSection([{ id: 'configurable', order: 0, label: 'Configurable' }])
  133. expect(screen.getByRole('heading', { name: en.title })).toBeTruthy()
  134. expect(screen.getByText(en.intro)).toBeTruthy()
  135. })
  136. it('moves focus and selection with standard horizontal tab keys', () => {
  137. renderSection([
  138. { id: 'configurable', order: 0, label: 'Configurable' },
  139. { id: 'all', order: 10, label: 'Plugin list' },
  140. { id: 'diagnostics', order: 20, label: 'Diagnostics' },
  141. ])
  142. const configurable = screen.getByRole('tab', { name: 'Configurable' })
  143. const all = screen.getByRole('tab', { name: 'Plugin list' })
  144. const diagnostics = screen.getByRole('tab', { name: 'Diagnostics' })
  145. expect(configurable.getAttribute('tabindex')).toBe('0')
  146. expect(all.getAttribute('tabindex')).toBe('-1')
  147. configurable.focus()
  148. fireEvent.keyDown(configurable, { key: 'ArrowRight' })
  149. expect(document.activeElement).toBe(all)
  150. expect(all.getAttribute('aria-selected')).toBe('true')
  151. fireEvent.keyDown(all, { key: 'End' })
  152. expect(document.activeElement).toBe(diagnostics)
  153. fireEvent.keyDown(diagnostics, { key: 'ArrowRight' })
  154. expect(document.activeElement).toBe(configurable)
  155. fireEvent.keyDown(configurable, { key: 'ArrowLeft' })
  156. expect(document.activeElement).toBe(diagnostics)
  157. fireEvent.keyDown(diagnostics, { key: 'Home' })
  158. expect(document.activeElement).toBe(configurable)
  159. fireEvent.keyDown(configurable, { key: 'Escape' })
  160. expect(document.activeElement).toBe(configurable)
  161. expect(configurable.getAttribute('aria-selected')).toBe('true')
  162. })
  163. })
  164. describe('BashCard', () => {
  165. it('renders its one-liner alone in the summary view', () => {
  166. renderBashCard({}, 'summary')
  167. expect(document.body.textContent).toBe(en.bashDescription)
  168. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  169. })
  170. it('says the plugin is not loaded in place of its fields while its namespace is unavailable', () => {
  171. renderBash({ available: false })
  172. expect(screen.getByRole('status').textContent).toBe(en.unavailable)
  173. expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull()
  174. })
  175. it('shows its fields at once on its page, without a title of its own', () => {
  176. renderBash()
  177. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  178. expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy()
  179. expect(screen.queryByText(en.bashTitle)).toBeNull()
  180. })
  181. it('stages an edit instead of writing it', () => {
  182. const actions = renderBash()
  183. fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } })
  184. expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000')
  185. expect(actions.save).not.toHaveBeenCalled()
  186. })
  187. it('offers the reset for an overridden field only', () => {
  188. const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) })
  189. // One badge and one reset: the output cap is still inherited.
  190. expect(screen.getAllByText(en.overridden)).toHaveLength(1)
  191. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  192. expect(actions.resetField).toHaveBeenCalledWith('timeoutMs')
  193. })
  194. it('addresses each of its two fields separately', () => {
  195. const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) })
  196. fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } })
  197. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  198. expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024')
  199. expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes')
  200. })
  201. it('keeps the save inert until something is staged, and offers no discard', () => {
  202. renderBash()
  203. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  204. expect(screen.getAllByRole('button')).toHaveLength(1)
  205. })
  206. it('writes the staged edits when saved', () => {
  207. const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) })
  208. fireEvent.click(screen.getByRole('button', { name: en.save }))
  209. expect(actions.save).toHaveBeenCalledOnce()
  210. expect(actions.discard).not.toHaveBeenCalled()
  211. })
  212. it('drops the staged edits when it leaves the page', () => {
  213. const actions = renderBash({ dirty: true })
  214. expect(actions.discard).not.toHaveBeenCalled()
  215. cleanup()
  216. expect(actions.discard).toHaveBeenCalledOnce()
  217. })
  218. it('blocks the save while a draft is invalid, and says why', () => {
  219. renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) })
  220. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  221. expect(screen.getByText(en.invalidNumber)).toBeTruthy()
  222. })
  223. it('reports a save in flight and refuses another', () => {
  224. renderBash({ dirty: true, saving: true })
  225. expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
  226. })
  227. it('reports a save the deployment did not accept and keeps the fields for correction', () => {
  228. renderBash({ dirty: true, failed: true })
  229. expect(screen.getByText(en.saveFailed)).toBeTruthy()
  230. expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy()
  231. })
  232. it('says the document is read-only and disables its controls', () => {
  233. renderBash({ writable: false })
  234. expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly)
  235. expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true)
  236. })
  237. })
  238. describe('Subagent model selection fields', () => {
  239. it('renders the default-off preference in its staged plugin card', () => {
  240. const actions = renderSubagentModelSelection()
  241. const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
  242. expect(control.getAttribute('aria-checked')).toBe('false')
  243. fireEvent.click(control)
  244. expect(actions.toggleEnabled).toHaveBeenCalledOnce()
  245. })
  246. it('groups available adapter candidates by provider', () => {
  247. const actions = renderSubagentModelSelection({
  248. enabled: true,
  249. candidates: [
  250. {
  251. key: 'alpha\0fast',
  252. provider: 'alpha',
  253. model: 'fast',
  254. providerName: 'Alpha API',
  255. modelName: 'Fast',
  256. available: true,
  257. selected: true,
  258. },
  259. {
  260. key: 'alpha\0deep',
  261. provider: 'alpha',
  262. model: 'deep',
  263. providerName: 'Alpha API',
  264. modelName: 'Deep',
  265. available: true,
  266. selected: false,
  267. },
  268. ],
  269. catalogStatus: 'ready',
  270. })
  271. expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true')
  272. expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy()
  273. fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ }))
  274. fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ }))
  275. expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast')
  276. expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep')
  277. })
  278. it('renders directory progress, failures, unavailable routes, and validation', () => {
  279. renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true })
  280. expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy()
  281. expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy()
  282. cleanup()
  283. const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' })
  284. fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry }))
  285. expect(errorActions.retryCatalog).toHaveBeenCalledOnce()
  286. cleanup()
  287. renderSubagentModelSelection({
  288. enabled: true,
  289. catalogStatus: 'ready',
  290. catalogPartial: true,
  291. candidates: [{
  292. key: 'legacy\0old',
  293. provider: 'legacy',
  294. model: 'old',
  295. providerName: 'legacy',
  296. modelName: 'old',
  297. available: false,
  298. selected: true,
  299. }],
  300. })
  301. expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy()
  302. expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy()
  303. expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy()
  304. cleanup()
  305. renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' })
  306. expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy()
  307. })
  308. it('distinguishes a stale draft from a rejected save', () => {
  309. renderSubagentModelSelection({ dirty: true, conflicted: true })
  310. expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy()
  311. expect(screen.queryByText(en.saveFailed)).toBeNull()
  312. })
  313. it('renders its one-liner in the summary view, says so when unavailable, and disables writes when read-only', () => {
  314. renderSubagentModelSelection({}, 'summary')
  315. expect(document.body.textContent).toBe(en.subagentDescription)
  316. cleanup()
  317. renderSubagentModelSelection({ available: false })
  318. expect(screen.getByRole('status').textContent).toBe(en.unavailable)
  319. cleanup()
  320. const actions = renderSubagentModelSelection({ writable: false })
  321. const control = screen.getByRole('switch') as HTMLButtonElement
  322. expect(control.disabled).toBe(true)
  323. fireEvent.click(control)
  324. expect(actions.toggleEnabled).not.toHaveBeenCalled()
  325. })
  326. })
  327. describe('AgentLoopCard', () => {
  328. it('renders its one-liner alone in the summary view', () => {
  329. const store = createSnapshotStore<AgentLoopCardState>({ ...settled, maxParallelToolCalls: field('10') })
  330. const props = { ...cardActions(), view: 'summary', t, useAgentLoopCard: bindSnapshotSelector(store) } as unknown as AgentLoopCardProps
  331. render(<AgentLoopCard {...props} />)
  332. expect(document.body.textContent).toBe(en.agentLoopDescription)
  333. expect(screen.queryByLabelText(en.agentLoopMaxParallel)).toBeNull()
  334. })
  335. it('stages and saves the only field it owns', () => {
  336. const store = createSnapshotStore<AgentLoopCardState>({
  337. ...settled,
  338. dirty: true,
  339. maxParallelToolCalls: field('10'),
  340. })
  341. const actions = cardActions()
  342. const props = {
  343. ...actions,
  344. view: 'page',
  345. t,
  346. useAgentLoopCard: bindSnapshotSelector(store),
  347. } as unknown as AgentLoopCardProps
  348. render(<AgentLoopCard {...props} />)
  349. fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } })
  350. fireEvent.click(screen.getByRole('button', { name: en.save }))
  351. expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2')
  352. expect(actions.save).toHaveBeenCalledOnce()
  353. })
  354. it('stages a reset for the field it owns', () => {
  355. const store = createSnapshotStore<AgentLoopCardState>({
  356. ...settled,
  357. maxParallelToolCalls: field('2', { overridden: true }),
  358. })
  359. const actions = cardActions()
  360. const props = {
  361. ...actions,
  362. view: 'page',
  363. t,
  364. useAgentLoopCard: bindSnapshotSelector(store),
  365. } as unknown as AgentLoopCardProps
  366. render(<AgentLoopCard {...props} />)
  367. fireEvent.click(screen.getByRole('button', { name: en.reset }))
  368. expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls')
  369. })
  370. })
  371. describe('WebSearchCard', () => {
  372. function renderWebSearch(state: Partial<WebSearchCardState> = {}) {
  373. const store = createSnapshotStore<WebSearchCardState>({
  374. ...settled,
  375. baseURL: field(''),
  376. maxUses: field('5'),
  377. apiKey: field(''),
  378. apiKeyConfigured: false,
  379. apiKeyWritable: true,
  380. ...state,
  381. })
  382. const actions = cardActions()
  383. const props = { ...actions, view: 'page', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
  384. render(<WebSearchCard {...props} />)
  385. return actions
  386. }
  387. it('renders its one-liner alone in the summary view', () => {
  388. const store = createSnapshotStore<WebSearchCardState>({
  389. ...settled, baseURL: field(''), maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, apiKeyWritable: true,
  390. })
  391. const props = { ...cardActions(), view: 'summary', t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps
  392. render(<WebSearchCard {...props} />)
  393. expect(document.body.textContent).toBe(en.webSearchDescription)
  394. expect(screen.queryByLabelText(en.webSearchApiKey)).toBeNull()
  395. })
  396. it('reports whether a key is configured without ever showing one', () => {
  397. renderWebSearch({ apiKeyConfigured: true })
  398. expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy()
  399. expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password')
  400. })
  401. it('keeps the key control usable while the settings document is read-only', () => {
  402. const actions = renderWebSearch({ writable: false })
  403. const key = screen.getByLabelText(en.webSearchApiKey)
  404. expect(key).toHaveProperty('disabled', false)
  405. expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true)
  406. fireEvent.change(key, { target: { value: 'ds-secret' } })
  407. expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret')
  408. })
  409. it('disables the key control when the reference itself is not writable', () => {
  410. // A key coming from the process environment: the settings document is
  411. // writable, the credential is not.
  412. renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false })
  413. expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true)
  414. expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false)
  415. })
  416. it('stages the endpoint, the search budget, and their resets', () => {
  417. const actions = renderWebSearch({
  418. baseURL: field('https://search.test/v1', { overridden: true }),
  419. maxUses: field('3', { overridden: true }),
  420. })
  421. fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } })
  422. fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } })
  423. const resets = screen.getAllByRole('button', { name: en.reset })
  424. expect(resets).toHaveLength(2)
  425. for (const reset of resets) fireEvent.click(reset)
  426. expect(actions.edit.mock.calls).toEqual([
  427. ['baseURL', 'https://other.test'],
  428. ['maxUses', '4'],
  429. ])
  430. expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']])
  431. })
  432. })
  433. describe('SubagentCard', () => {
  434. it('discards both drafts when leaving the page', () => {
  435. const { actions } = renderSubagent({ dirty: true }, { dirty: true })
  436. cleanup()
  437. expect(actions.discard).toHaveBeenCalledOnce()
  438. })
  439. it('renders limits without model selection when only limits are served', () => {
  440. renderSubagent({}, { available: false })
  441. expect(screen.getByLabelText(en.subagentMaxDepth)).toBeTruthy()
  442. expect(screen.queryByRole('switch')).toBeNull()
  443. })
  444. it('shows both sections on one page with one save footer', () => {
  445. renderSubagent({ dirty: true })
  446. expect(screen.getByRole('heading', { name: en.subagentLimitsTitle })).toBeTruthy()
  447. expect(screen.getByRole('heading', { name: en.subagentModelSelectionTitle })).toBeTruthy()
  448. expect(screen.getAllByRole('button', { name: en.save })).toHaveLength(1)
  449. })
  450. it('reveals field rules on demand without changing staged values', () => {
  451. renderSubagent({ dirty: true, maxDepth: field('2') })
  452. const depthHelp = screen.getByRole('button', { name: en.subagentDepthHelpLabel })
  453. const capacityHelp = screen.getByRole('button', { name: en.subagentCapacityHelpLabel })
  454. expect(depthHelp.getAttribute('aria-expanded')).toBe('false')
  455. expect(screen.queryByText(en.subagentDepthHelp)).toBeNull()
  456. expect(screen.queryByText(en.subagentCapacityHelp)).toBeNull()
  457. fireEvent.click(depthHelp)
  458. expect(depthHelp.getAttribute('aria-expanded')).toBe('true')
  459. expect(screen.getByRole('region', { name: en.subagentDepthHelpLabel })).toBeTruthy()
  460. expect(screen.getByText(en.subagentDepthHelp)).toBeTruthy()
  461. expect(screen.getByRole('table', { name: en.subagentDepthHelpLabel })).toBeTruthy()
  462. expect(screen.getByRole('row', { name: `0 ${en.subagentDepthZero}` })).toBeTruthy()
  463. expect(screen.getByRole('row', { name: `1 ${en.subagentDepthOne}` })).toBeTruthy()
  464. expect(screen.getByText(en.subagentDepthOverride)).toBeTruthy()
  465. fireEvent.click(capacityHelp)
  466. expect(screen.getByText(en.subagentCapacityHelp)).toBeTruthy()
  467. fireEvent.click(depthHelp)
  468. expect(screen.queryByText(en.subagentDepthHelp)).toBeNull()
  469. expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('value', '2')
  470. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false)
  471. })
  472. it('keeps validation visible when the rules are collapsed and links it to the input', () => {
  473. renderSubagent({ dirty: true, invalid: true, maxDepth: field('1.5', { invalid: true }) })
  474. const depth = screen.getByLabelText(en.subagentMaxDepth)
  475. const messageId = depth.getAttribute('aria-describedby')!
  476. expect(document.getElementById(messageId)?.textContent).toBe(en.subagentDepthInvalid)
  477. expect(screen.queryByRole('region', { name: en.subagentDepthHelpLabel })).toBeNull()
  478. })
  479. it('edits and resets limits through the shared card', () => {
  480. const { actions, limits } = renderSubagent({
  481. dirty: true,
  482. maxDepth: field('3', { overridden: true }),
  483. maxActiveSubagents: field('8', { overridden: true }),
  484. })
  485. fireEvent.change(screen.getByLabelText(en.subagentMaxDepth), { target: { value: '2' } })
  486. fireEvent.change(screen.getByLabelText(en.subagentMaxActive), { target: { value: '12' } })
  487. expect(actions.editLimit.mock.calls).toEqual([['maxDepth', '2'], ['maxActiveSubagents', '12']])
  488. for (const button of screen.getAllByRole('button', { name: en.reset })) fireEvent.click(button)
  489. expect(actions.resetLimit.mock.calls).toEqual([['maxDepth'], ['maxActiveSubagents']])
  490. act(() => { limits.set({ ...limits.getSnapshot(), writable: false }) })
  491. expect(screen.getByLabelText(en.subagentMaxActive)).toHaveProperty('disabled', true)
  492. })
  493. it('blocks saving both sections when a model selection is invalid or conflicted', () => {
  494. const { models } = renderSubagent({ dirty: true }, { dirty: true, invalid: true })
  495. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  496. act(() => { models.set({ ...models.getSnapshot(), invalid: false, conflicted: true }) })
  497. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  498. })
  499. it('locks both sections while either is saving and stays open after both settle', () => {
  500. const { limits, models } = renderSubagent({ dirty: true }, { dirty: true })
  501. act(() => {
  502. limits.set({ ...limits.getSnapshot(), saving: true })
  503. models.set({ ...models.getSnapshot(), saving: true })
  504. })
  505. expect(screen.getByLabelText(en.subagentMaxDepth)).toHaveProperty('disabled', true)
  506. expect(screen.getByRole('switch')).toHaveProperty('disabled', true)
  507. expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true)
  508. act(() => { limits.set({ ...limits.getSnapshot(), saving: false, dirty: false }) })
  509. expect(screen.getByRole('switch')).toHaveProperty('disabled', true)
  510. act(() => { models.set({ ...models.getSnapshot(), saving: false, dirty: false }) })
  511. expect(screen.getByRole('switch')).toHaveProperty('disabled', false)
  512. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true)
  513. })
  514. it('keeps a rejected section open after the other section saves', () => {
  515. const { limits, models } = renderSubagent({ dirty: true }, { dirty: true })
  516. act(() => {
  517. limits.set({ ...limits.getSnapshot(), saving: true })
  518. models.set({ ...models.getSnapshot(), saving: true })
  519. })
  520. act(() => {
  521. limits.set({ ...limits.getSnapshot(), saving: false, dirty: false })
  522. models.set({ ...models.getSnapshot(), saving: false, failed: true })
  523. })
  524. expect(screen.getByRole('switch')).toBeTruthy()
  525. expect(screen.getByText(en.saveFailed)).toBeTruthy()
  526. expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', false)
  527. })
  528. it('renders model-only deployments without limit controls', () => {
  529. renderSubagent({ available: false })
  530. expect(screen.getByRole('switch')).toBeTruthy()
  531. expect(screen.queryByLabelText(en.subagentMaxDepth)).toBeNull()
  532. })
  533. })