diff --git a/web/AGENTS.md b/web/AGENTS.md index c026263f..a85898bd 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -145,10 +145,25 @@ ### 3.14 测试 - 工具函数与纯逻辑优先单元测试(Vitest),测试文件 `*.test.ts`;组件用 React Testing Library 测交互与行为,避免测实现细节。 +- 新增功能、修复缺陷或修改现有行为时,必须同步新增或更新测试;Bug 修复必须先编写能够稳定复现问题的失败用例,再实现修复并确认用例转为通过。 +- 修改前端组件的布局、尺寸、滚动定位、焦点管理、键盘操作、选中状态、禁用状态、加载状态、空状态、错误状态或响应式行为时,必须补充对应的回归测试,覆盖本次变更保护的用户可见行为,防止后续调整重新引入问题。 +- 功能模块或组件模块的测试必须放在该模块专属的 `__tests__/` 目录中,例如 `src/components/model-group-selector/__tests__/layout.test.ts`;禁止将新增测试文件与正式代码文件平铺在同一目录。 +- 测试文件按被测职责命名,例如 `layout.test.ts`、`selection.test.ts`、`validation.test.ts`;一个测试文件只覆盖一个明确模块或职责,避免形成跨模块的超大测试文件。 +- 每个测试用例应只保护一个可描述的行为,名称必须包含触发条件和预期结果;优先使用 Arrange、Act、Assert 的清晰结构,避免在单个用例中混合多个无关断言。 +- 测试必须覆盖主要成功路径以及本次变更涉及的关键边界和失败路径,包括空数据、单项和多项数据、超长文本、无效输入、禁用状态、异步失败与降级逻辑;不得为了数量机械枚举不相关输入。 +- 布局测试应断言明确且稳定的行为契约,例如固定尺寸、排列方向、溢出策略、滚动目标和降级路径;不要仅断言组件能够渲染,也不要依赖浏览器像素误差、浏览器私有实现或脆弱的完整 class 字符串快照。 +- 组件交互测试应从用户视角查询元素并执行点击、输入、键盘和焦点操作,断言可见结果、可访问状态或对外回调;禁止直接断言组件内部 state、私有函数调用次数或无用户意义的 DOM 层级。 +- 涉及可访问性的组件必须覆盖可访问名称、键盘可操作性,以及 `aria-expanded`、`aria-selected`、`aria-disabled`、`aria-invalid` 等与视觉状态一致的属性。 +- 涉及 i18n 文案的测试优先通过稳定的角色、label 或翻译键语义定位元素,避免将某一种语言的完整展示文案作为与业务无关的脆弱断言;若翻译内容本身是契约,则应明确覆盖语言切换或 fallback 行为。 +- 异步测试必须等待明确的界面状态或 Promise 结果,不得使用固定 `sleep`、依赖执行耗时或制造竞态;定时器、网络请求和浏览器 API 仅在必要边界进行可控 mock,并在每个用例后恢复。 +- 优先测试真实代码路径;只有外部网络、时间、随机数、存储或浏览器 API 等不可控边界可以 mock。禁止 mock 被测模块自身,也不要通过复制生产逻辑到测试中计算期望结果。 +- 测试数据应使用最小且具有业务含义的显式 fixture,测试内部必须独立初始化并清理全局状态、缓存、localStorage、mock 和定时器,确保用例可单独运行且不依赖执行顺序。 +- 快照测试仅适用于稳定且人工可审查的结构输出;交互组件、复杂 DOM 和 Tailwind class 列表不得使用大范围快照代替行为断言。 - 关键流程补充集成与 E2E(如 MSW 模拟 API、Playwright/Cypress);核心功能目标覆盖率 80% 以上,关注业务路径与关键分支。 - 测试必须保护真实用户行为、稳定 API 契约或明确回归路径;禁止为了覆盖率添加 smoke、sleep/timing、随机输入、日志输出或只证明代码运行的测试。 - 新增或大幅重写测试时优先使用 Vitest 与 React Testing Library 的标准断言和查询方式,避免手写通用断言辅助函数;只有表达项目特定业务不变量时才抽取测试 helper。 - 清理测试时先合并重复场景、删除不明不白的实现细节断言;若旧测试间接覆盖了真实契约,需替换为更小、更直接的行为测试。 +- 提交前必须至少运行受影响测试文件,并根据影响范围执行相关测试集、`bun run typecheck` 和涉及文件的 lint;不得在未看到最新通过结果的情况下声明测试完成。 ### 3.15 依赖管理 diff --git a/web/src/components/model-group-selector.tsx b/web/src/components/model-group-selector.tsx index 06367ae9..860344a9 100644 --- a/web/src/components/model-group-selector.tsx +++ b/web/src/components/model-group-selector.tsx @@ -65,7 +65,7 @@ import { cn } from '@/lib/utils' import { modelGroupSelectorLayoutClasses, scrollSelectedOptionIntoView, -} from './model-group-selector-layout' +} from './model-group-selector/layout' interface ModelOption { label: string diff --git a/web/src/components/model-group-selector/__tests__/layout.test.ts b/web/src/components/model-group-selector/__tests__/layout.test.ts new file mode 100644 index 00000000..ef5b7e6f --- /dev/null +++ b/web/src/components/model-group-selector/__tests__/layout.test.ts @@ -0,0 +1,68 @@ +/* +Copyright (C) 2023-2026 QuantumNous + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +For commercial licensing, please contact support@quantumnous.com +*/ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { + modelGroupSelectorLayoutClasses, + scrollSelectedOptionIntoView, +} from '../layout' + +describe('model group selector layout', () => { + test('keeps group options at a fixed height and aligned to the top', () => { + const groupScrollClasses = + modelGroupSelectorLayoutClasses.groupScroll.split(' ') + + assert.ok(groupScrollClasses.includes('auto-rows-[2rem]')) + assert.ok(groupScrollClasses.includes('content-start')) + }) + + test('centers the selected group inside its own scroll container', () => { + const scrollCalls: ScrollToOptions[] = [] + const selectedOption = { + offsetHeight: 32, + offsetTop: 160, + scrollIntoView() {}, + } + const scrollContainer = { + clientHeight: 200, + scrollTop: 0, + scrollTo(options: ScrollToOptions) { + scrollCalls.push(options) + }, + } + + scrollSelectedOptionIntoView(selectedOption, scrollContainer) + + assert.deepEqual(scrollCalls, [{ top: 76, behavior: 'auto' }]) + }) + + test('falls back to scrollIntoView when no group container is provided', () => { + const scrollCalls: ScrollIntoViewOptions[] = [] + const selectedOption = { + scrollIntoView(options?: ScrollIntoViewOptions) { + scrollCalls.push(options ?? {}) + }, + } + + scrollSelectedOptionIntoView(selectedOption) + + assert.deepEqual(scrollCalls, [{ block: 'center', inline: 'nearest' }]) + }) +}) diff --git a/web/src/components/model-group-selector-layout.ts b/web/src/components/model-group-selector/layout.ts similarity index 96% rename from web/src/components/model-group-selector-layout.ts rename to web/src/components/model-group-selector/layout.ts index 12387dc4..0dc9bbdd 100644 --- a/web/src/components/model-group-selector-layout.ts +++ b/web/src/components/model-group-selector/layout.ts @@ -21,7 +21,8 @@ export const modelGroupSelectorLayoutClasses = { desktopContent: 'grid h-[min(50vh,28rem)] max-h-[min(50vh,28rem)] min-h-0 gap-3 p-2 md:grid-cols-[9.5rem_minmax(0,1fr)]', groupColumn: 'flex h-full min-h-0 min-w-0 flex-col overflow-hidden', - groupScroll: 'mt-2 grid min-h-0 flex-1 gap-1 overflow-y-auto pr-1', + groupScroll: + 'mt-2 grid min-h-0 flex-1 auto-rows-[2rem] content-start gap-1 overflow-y-auto pr-1', modelColumn: 'flex h-full min-h-0 min-w-0 overflow-hidden rounded-lg border', modelCommand: 'min-h-0 flex-1 rounded-lg border-0 bg-transparent p-1', modelList: