diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index c96d102bdd..f6a0acfa63 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -70,12 +70,12 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: /** * Read how the composer draws one long-copy element: the lines it shows, - * whether CSS clipped it, whether its text overflows the line budget, and the - * label of its toggle while that toggle is visible. + * whether CSS clipped it, whether its text overflows the line budget, and what + * its toggle does while that toggle is visible. * @param element - a question heading or an option description. * @returns the drawn state of the copy. */ -function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: string | undefined } { +function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } { const style = getComputedStyle(element) const toggle = element.tagName === 'H2' ? element.nextElementSibling @@ -86,7 +86,7 @@ function readCopy(element: HTMLElement): { lines: number; clipped: boolean; over clipped: style.maskImage !== 'none', overflows: element.scrollHeight > element.clientHeight + 0.5, toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible' - ? toggle.textContent ?? undefined + ? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand' : undefined, } } @@ -414,7 +414,7 @@ describe('web e2e: resident question composer round trip', () => { const copy = await description.evaluate(readCopy) expect(copy.lines).toBeLessThanOrEqual(2) expect(copy.clipped).toBe(copy.overflows) - expect(copy.toggle).toBe(copy.clipped ? 'More' : undefined) + expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined) } scrollStates.push(squeeze.scrolls) expect(squeeze.titleInScrollport).toBe(true) @@ -432,14 +432,14 @@ describe('web e2e: resident question composer round trip', () => { const blue = composer.getByRole('checkbox', { name: 'Blue' }) const blueText = composer.locator('[data-option-description]').first() const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`) - await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await blueToggle.click() expect(await blue.getAttribute('aria-checked')).toBe('false') expect(await blueToggle.getAttribute('aria-expanded')).toBe('true') - expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) await blueToggle.click() // A collapsed copy's scroll timeline reports the overflow from the next frame. - await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await page.setViewportSize(originalViewport) // Multi-line custom answer: the field is a textarea whose hidden mirror @@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => { await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() expect(await composer.getByRole('heading').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) - expect(await composer.getByRole('button', { name: 'More' }).count()).toBe(0) + expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0) const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -726,15 +726,15 @@ describe('web e2e: resident question composer round trip', () => { // Past its three-line budget the question keeps two lines; past its // two-line budget a description keeps one. Copy within budget shows in full. await expect.poll(() => heading.evaluate(readCopy)) - .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) expect(await descriptionOf('Explicit install').evaluate(readCopy)) - .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) expect(await descriptionOf('Keep the default edge').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) for (const copy of await composer.locator('[data-option-description]').all()) { const drawn = await copy.evaluate(readCopy) expect(drawn.clipped).toBe(drawn.overflows) - expect(drawn.toggle).toBe(drawn.clipped ? 'More' : undefined) + expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined) expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2) } if (viewport.width === 512) { @@ -758,15 +758,17 @@ describe('web e2e: resident question composer round trip', () => { // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') + expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question') await questionToggle.click() expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question') const expandedQuestion = await heading.evaluate(readCopy) - expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedQuestion.lines).toBeGreaterThan(3) expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true) expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true }) await questionToggle.click() - await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) @@ -782,10 +784,11 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await explicitText.getAttribute('id') expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”') await descriptionToggle.click() expect(await explicit.getAttribute('aria-checked')).toBe('false') const expandedDescription = await explicitText.evaluate(readCopy) - expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedDescription.lines).toBeGreaterThan(2) expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true) expect(await explicitText.evaluate((text) => { @@ -794,7 +797,7 @@ describe('web e2e: resident question composer round trip', () => { child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5) })).toBe(true) - // The toggle's ink stays readable on the card in both themes. + // The toggle's icon keeps non-text contrast on the card in both themes. const palette: string[] = [] const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') try { @@ -814,7 +817,7 @@ describe('web e2e: resident question composer round trip', () => { const foreground = luminance(colors.text) const background = luminance(colors.surface) expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)) - .toBeGreaterThanOrEqual(4.5) + .toBeGreaterThanOrEqual(3) palette.push(colors.surface) } expect(palette[0]).not.toBe(palette[1]) @@ -826,7 +829,7 @@ describe('web e2e: resident question composer round trip', () => { } await descriptionToggle.click() await expect.poll(() => explicitText.evaluate(readCopy)) - .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await composer.getByRole('checkbox', { name: 'Explicit install' }).click() const answer = composer.getByRole('textbox') diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 68f0c3442a..974ab9c64e 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: fda2aac4a93d6550 - zh: aa5ba8322b566f37 + en: 2173107a23e9f92a + zh: d3a6d17e50dd4b51 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index f31e20910f..9f2fe46110 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and offers **More** there, which expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and **More**. Expanded copy keeps **Show less** on a line below it. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index c487cdc47b..2ac78bf4c1 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并提供「全部」,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并提供「全部」。展开后的文字下方单独一行显示「收起」。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index a2de7c3ff6..fa75de76e4 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -109,7 +109,7 @@ /* Long copy keeps one line fewer than its budget once it overflows: a question shows in full up to three lines, an option description up to two; longer copy drops its last line, fades at the end of the line before, and - shows its toggle there. Each box keeps its full budget, plus a 2px + shows its expand button there. Each box keeps its full budget, plus a 2px allowance for glyphs taller than the line, so the scroll timeline that reports the overflow never depends on the collapsed layout. A negative bottom margin, not a smaller box, gives the dropped line back to the row. */ @@ -121,7 +121,7 @@ } .questionTitle[data-expanded] { - padding-bottom: 22px; + padding-bottom: 24px; } .titlePreview { @@ -142,7 +142,7 @@ @keyframes question-clipped { from, to { margin-bottom: calc(-1lh - 2px); - mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px)); + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh, 100% 1lh; mask-position: 0 0, 0 1lh; mask-repeat: no-repeat; @@ -151,8 +151,8 @@ .questionToggle { position: absolute; - right: -6px; - bottom: 0; + right: -4px; + bottom: -1px; animation-name: copy-toggle-shown; animation-fill-mode: both; animation-timeline: --question-copy; @@ -400,7 +400,7 @@ @keyframes description-clipped { from, to { margin-bottom: calc(-1lh - 2px); - mask-image: linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px)); + mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh; mask-repeat: no-repeat; } @@ -408,16 +408,16 @@ .descriptionToggle { position: absolute; - right: 6px; - bottom: 7px; + right: 8px; + bottom: 6px; animation-name: copy-toggle-shown; animation-fill-mode: both; animation-timeline: --description-copy; } -/* Expanded copy keeps its toggle on a line of its own below the text. */ +/* Expanded copy keeps its collapse button on a line of its own below the text. */ .optionRow[data-expanded] .option { - padding-bottom: 30px; + padding-bottom: 32px; } .questionTitle[data-expanded] .titlePreview, @@ -426,18 +426,8 @@ animation: none; } +/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */ .copyToggle { - padding: 1px 6px; - border: 0; - border-radius: var(--dsw-radius-sm); - background: transparent; - color: var(--dsw-alias-label-secondary); - font: inherit; - font-size: 13px; - line-height: 20px; - font-weight: 400; - white-space: nowrap; - cursor: pointer; visibility: hidden; } @@ -453,13 +443,6 @@ animation: none; } -@media (hover: hover) { - .copyToggle:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); - } -} - /* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */ @supports not (animation-timeline: scroll()) { .titlePreview, @@ -640,7 +623,7 @@ } .descriptionToggle { - right: 0; + right: 2px; } .footer { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index cfd8eeed0c..b8cdeae85b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - IconEditOutlineRegular, MarkdownText, + IconEditOutlineRegular, MarkdownText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -250,6 +250,8 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // CSS decides which copy is clipped; only the user's expansions are state. const [expanded, setExpanded] = useState(NOTHING_EXPANDED) + const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand') + const toggleCopy = (copy: 'question' | number): void => { setExpanded((current) => { const next = new Set(current) @@ -524,15 +526,18 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi > {question.question} - + + + {question.detail !== undefined && (
@@ -543,6 +548,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' const descriptionExpanded = expanded.has(optionIndex) + const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label }) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
{hasDescription && ( - + + + )}
) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 4995c365e4..0e8d3e6bdf 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,8 +25,10 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', - 'text.expand': '全部', - 'text.collapse': '收起', + 'question.expand': '展开完整问题', + 'question.collapse': '收起问题', + 'option.expand': '展开“{option}”的完整说明', + 'option.collapse': '收起“{option}”的说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -65,8 +67,10 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', - 'text.expand': 'More', - 'text.collapse': 'Show less', + 'question.expand': 'Show the full question', + 'question.collapse': 'Show less of the question', + 'option.expand': 'Show the full description of “{option}”', + 'option.collapse': 'Show less of “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index c1ab2c6f7f..7562833067 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -315,32 +315,34 @@ describe('QuestionComposer', () => { // CSS shows each toggle only while its copy overflows; every candidate is rendered. const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) - const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '全部' }) - expect(rest).toHaveLength(0) - expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id) + const questionToggle = screen.getByRole('button', { name: '展开完整问题' }) + const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' }) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) const text = screen.getByText(description) expect(option.getAttribute('aria-describedby')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-controls')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-describedby')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('false') + expect(descriptionToggle.getAttribute('aria-controls')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(descriptionToggle as HTMLElement) - expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true') - expect(descriptionToggle?.textContent).toBe('收起') + fireEvent.click(descriptionToggle) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明') expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') - expect(questionToggle?.getAttribute('aria-expanded')).toBe('false') + expect(questionToggle.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(questionToggle as HTMLElement) - expect(questionToggle?.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(questionToggle) + expect(questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(questionToggle.getAttribute('aria-label')).toBe('收起问题') expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) - fireEvent.click(questionToggle as HTMLElement) - fireEvent.click(descriptionToggle as HTMLElement) + fireEvent.click(questionToggle) + fireEvent.click(descriptionToggle) expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) - expect(screen.getAllByRole('button', { name: '全部' })).toEqual([questionToggle, descriptionToggle]) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) }) it('resets scrolling and expanded copy when the question changes', () => { @@ -361,8 +363,9 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle) - expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2) + for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle) + expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true') fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -370,11 +373,12 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) - expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull() expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - const descriptionToggle = screen.getAllByRole('button', { name: '全部' })[1] - expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' }) + expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -948,7 +952,7 @@ describe('review card', () => { expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) // Long copy stays readable on a frozen card. - expect(screen.getAllByRole('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull()