// tests/unit/renderer/components/ui/button-contract.test.tsx // // 守 Button 原语的**行为契约**(src/renderer/src/components/ui/Button.tsx 头注 // 指向本文件,audit-r118 登记 6 例 / 7 变异精确;本轮补齐,原先标 🔒(守卫缺失))。 // // Button 是全站按钮唯一入口(确认框 / 工具栏 / 错误页 / 设置页),它身上钉着 // 几件**别处不会重复**的语义: // 1. 默认 type="button" —— HTML ); expect(btn.getAttribute('type')).toBe('button'); unmount(); }); it('loading ⇒ disabled + aria-busy + spinner + 摘掉 onClick', () => { const onClick = vi.fn(); const { btn, container, unmount } = renderButton( , ); expect(btn.disabled, 'loading 期间必须禁用').toBe(true); expect(btn.getAttribute('aria-busy')).toBe('true'); // 文字不变 + spinner 在前(宽度不跳变) expect(btn.textContent).toContain('保存'); const svg = container.querySelector('svg'); expect(svg, 'loading 时应有 Loader2 图标').not.toBeNull(); expect(svg?.getAttribute('aria-hidden')).toBe('true'); expect(svg?.getAttribute('class')).toContain('animate-spin'); // 视觉:不可点 cursor + 轻微透明 expect(btn.className).toContain('cursor-not-allowed'); // disabled 按钮的 click 由浏览器/React 双重挡住 ⇒ onClick 不得触发 fireEvent.click(btn); expect(onClick).not.toHaveBeenCalled(); unmount(); }); it('loading 覆盖 disabled 的语义(两者都传也只 disabled 一次,不报错)', () => { const { btn, unmount } = renderButton( , ); expect(btn.disabled).toBe(true); expect(btn.getAttribute('aria-busy')).toBe('true'); unmount(); }); it('非 loading 时 disabled ⇒ 点击不触发 onClick;非 disabled ⇒ 触发一次', () => { const onClick = vi.fn(); const { btn, unmount } = renderButton( , ); fireEvent.click(btn); expect(onClick).not.toHaveBeenCalled(); unmount(); const onClick2 = vi.fn(); const { btn: btn2, unmount: unmount2 } = renderButton( , ); fireEvent.click(btn2); expect(onClick2).toHaveBeenCalledTimes(1); unmount2(); }); it('data-* 透传(r18:ConfirmDialog 的 autoFocusSelector 要靠它命中真按钮)', () => { const { btn, unmount } = renderButton( , ); expect(btn.getAttribute('data-testid')).toBe('confirm-ok'); expect(btn.getAttribute('data-confirm-role')).toBe('primary'); unmount(); }); it('非 data-* 的多余 prop 不落到 DOM(rest 白名单外一律丢弃)', () => { const { btn, unmount } = renderButton( , ); expect(btn.getAttribute('nonsense')).toBeNull(); unmount(); }); it('size 档位类名映射(xs/sm/md/lg,lg = h-11 的 44px 触摸目标)', () => { const cases: [ 'xs' | 'sm' | 'md' | 'lg', string ][] = [ ['xs', 'h-6'], ['sm', 'h-[26px]'], ['md', 'h-[30px]'], ['lg', 'h-11'], ]; for (const [size, expectCls] of cases) { const { btn, unmount } = renderButton(); expect(btn.className, `size=${size} 应带 ${expectCls}`).toContain(expectCls); unmount(); } }); it('variant 档位类名映射(primary/secondary/ghost/danger)+ 默认 secondary', () => { const variants = ['primary', 'secondary', 'ghost', 'danger'] as const; for (const v of variants) { const { btn, unmount } = renderButton(); expect(btn.className, `variant=${v} 应带 btn-${v}`).toContain(`btn-${v}`); unmount(); } const { btn, unmount } = renderButton(); expect(btn.className, '默认 variant 必须是 secondary').toContain('btn-secondary'); unmount(); }); it('aria-label / title 透传(缺省时属性不存在,不留空串)', () => { const { btn, unmount } = renderButton( , ); expect(btn.getAttribute('aria-label')).toBe('关闭面板'); expect(btn.getAttribute('title')).toBe('tooltip'); unmount(); const { btn: bare, unmount: unmount2 } = renderButton(); expect(bare.getAttribute('aria-label')).toBeNull(); expect(bare.getAttribute('title')).toBeNull(); unmount2(); }); it('className 由调用方追加(不覆盖内置档位类)', () => { const { btn, unmount } = renderButton(); expect(btn.className).toContain('my-cls'); expect(btn.className).toContain('btn'); unmount(); }); });