diff --git a/.gitignore b/.gitignore index dc413e3d53..1dd398fad7 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,6 @@ tsconfig.tsbuildinfo # codex .omx/ + +# Local visual regression runs +.vtable-visual/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..1a5b46f4f3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# VTable 开发约定 + +## 本地视觉测试 + +- 使用 Node.js 22,在仓库根目录运行视觉测试。开发完成后按改动范围执行相关单例或目录,例如 `node packages/vtable/scripts/visual-test.mjs --case list-basic` 或 `node packages/vtable/scripts/visual-test.mjs --dir pivot`;影响多个模块时扩大测试范围。 +- 提交 PR 前运行全量:`node packages/vtable/scripts/visual-test.mjs`。检查报告中的基线、当前和差异图;执行错误或无法运行时如实说明,不把 `--self-compare` 当作官方基线通过,也不通过跳过断言或放宽阈值掩盖差异。 +- 新增功能、修复 bug 或发现覆盖空缺时,可以新增有明确目的的确定性用例,或补强现有用例。新用例登记在 `packages/vtable/__tests__/visual/cases/index.mjs`;交互用例须执行真实动作并断言结果。新增或修改用例先运行单例 `--self-compare`,再运行相关目录。 +- 开发者或编码 Agent 独立新增的用例**完全省略** `BugServer case IDs` 行,不填写空值、占位或虚构 ID。由现有 BugServer case 迁移的用例保留真实来源 ID,合并来源时列出全部真实 ID。未来同步到 BugServer 成功后,再补写服务返回的真实 ID;本期没有同步脚本。 +- 环境准备、报告和清理见 [视觉测试说明](./packages/vtable/__tests__/visual/README.md);目录与用例格式见 [用例指南](./packages/vtable/__tests__/visual/cases/README.md)。本次任务启动的浏览器、服务和其他测试进程在结束时应停止。 diff --git a/packages/vtable/__tests__/visual/README.md b/packages/vtable/__tests__/visual/README.md new file mode 100644 index 0000000000..7bd5901701 --- /dev/null +++ b/packages/vtable/__tests__/visual/README.md @@ -0,0 +1,41 @@ +# VTable 本地视觉回归 + +本工具在本机将当前工作区与官方 `VisActor/VTable` 的 `develop` 构建逐例截图比较。用例由 BugServer Photo case 人工筛选、去业务化改写而来,另含少量独立的基础用例;运行不依赖 BugServer。两侧共用冻结的用例副本,只替换 VTable 构建产物。截图差异需要人工检查;自比较通过只说明运行稳定。 + +## 准备 + +在仓库根目录使用 Node.js 22,安装 Rush 依赖和锁文件对应的 Chromium: + +```sh +node common/scripts/install-run-rush.js install --ignore-hooks +PLAYWRIGHT_SKIP_BROWSER_GC=1 node packages/vtable/node_modules/@playwright/test/cli.js install chromium +``` + +Linux 还需安装 Chromium 的系统库。`--check` 会实际启动浏览器做预检,但不会安装依赖或构建。 + +## 运行 + +```sh +node packages/vtable/scripts/visual-test.mjs --check +node packages/vtable/scripts/visual-test.mjs --list +node packages/vtable/scripts/visual-test.mjs --case list-basic --self-compare +node packages/vtable/scripts/visual-test.mjs --dir pivot +node packages/vtable/scripts/visual-test.mjs +node packages/vtable/scripts/visual-test.mjs --baseline <完整的40位commit-sha> +``` + +`--case` 选择一个用例,`--dir` 递归选择一个相对目录,两者互斥;不传则运行全部。`--self-compare` 在两个隔离的浏览器上下文中执行相同的本地构建,用于检查用例确定性。默认基线每次从官方仓库获取 `develop` 并固定本轮 SHA;`--baseline` 从官方仓库获取指定 SHA。基线按自己的锁文件独立安装与构建,缓存最近一次成功的基线产物。当前工作区每轮构建包含未提交修改。 + +按用例需要构建核心 `vtable`,以及 `editors`、`gantt`、`plugins`、`sheet` 的本地 UMD 包;透视图还加载当前依赖锁定的 `vchart` 浏览器包。目录仅影响用例与所需包的选择;不会自动推断源码影响范围。 + +结果写入 Git 忽略的 `.vtable-visual/runs//`:`index.html` 可离线查看基线、当前和差异三图,`agent-summary.md` 便于快速定位失败,`summary.json` 保留结构化证据。退出码 `0` 表示所选用例均通过,`1` 表示视觉差异,`2` 表示执行、构建、资源或清理错误。报告可以整体复制,单独复制 HTML 会丢失相对图片。 + +正常结束、失败或 Ctrl+C 后,工具会回收本轮测试子进程、浏览器、HTTP 服务和临时基线 worktree。SIGKILL/断电后,先确认进程已结束,再检查 `git worktree list` 和 `.vtable-visual/running.lock`;不要删除其他任务的 worktree。 + +## 用例维护 + +目录说明及来源 ID 规则见 [用例指南](./cases/README.md)。开发完成后运行相关目录;提交 PR 前运行默认全量比较并检查差异。新功能或缺陷修复可以加独立用例。不得用放宽截图阈值、跳过语义断言或接受差异代替修复不稳定用例。工具自检: + +```sh +node --test packages/vtable/scripts/visual-test.test.mjs +``` diff --git a/packages/vtable/__tests__/visual/cases/COVERAGE.md b/packages/vtable/__tests__/visual/cases/COVERAGE.md new file mode 100644 index 0000000000..282ad24f36 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/COVERAGE.md @@ -0,0 +1,20 @@ +# VTable 本地视觉覆盖 + +[`index.mjs`](./index.mjs) 是唯一可执行清单;在仓库根目录运行 `node packages/vtable/scripts/visual-test.mjs --list` 可查看所有用例的 ID、目的和路径。每个用例只验证其固定输入、动作与断言,不能代表相邻配置或 BugServer 全部用例都已覆盖。 + +| 用例目录 | 已有代表场景 | +| --- | --- | +| `table`、`header`、`cells` | 列表与多级表头、合并单元格、行序号、文本/复选框/开关/按钮/图片/链接等单元格类型 | +| `pivot`、`pivot-chart` | 维度、聚合与汇总、指标隐藏、排序、冻结、图例,以及面积图、玫瑰图、箱线图、热力图、散点图、旭日图等图表和交互 | +| `gantt`、`sheet`、`plugins` | 任务条与基线、公式及行列插入、筛选/填充柄/主从表/Excel 键盘插件 | +| `layout`、`frozen`、`scroll`、`theme`、`style`、`language`、`empty` | 自动尺寸、冻结区与阴影、滚动、主题和样式、多语言及空状态 | +| `interaction`、`keyboard`、`edit`、`sort`、`menu` | 悬停与选择、拖动和尺寸调整、复制粘贴、编辑、排序及菜单 | +| `analysis`、`records`、`tree`、`group`、`transpose`、`api`、`data`、`components`、`custom` | 过滤与聚合、记录更新、树/分组、转置、异步数据、API 更新和自定义布局 | + +## 当前边界 + +- Sheet 公式复制填充目前断言邻格显示值,未验证公式引用随位置平移。 +- 远程媒体资源、大规模数据的性能,以及透视树扩展标题路径与懒加载、多级分组的大规模动态更新,不属于当前本地视觉用例的已验证范围。 +- 官方基线无法正常执行的来源不登记为通过用例。新增功能或修复缺陷时,优先增加对应触发条件和结果断言;不要以用例数量或配置关键词推断覆盖率。 + +运行方式、报告和失败含义见[视觉测试说明](../README.md),新增用例与真实 BugServer 来源 ID 规则见[用例指南](./README.md)。 diff --git a/packages/vtable/__tests__/visual/cases/README.md b/packages/vtable/__tests__/visual/cases/README.md new file mode 100644 index 0000000000..1b2d86d10f --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/README.md @@ -0,0 +1,21 @@ +# VTable 视觉用例 + +`index.mjs` 是唯一可执行清单;一份用例一个模块,按主要验证目的归类。用 `--list` 查看实际 ID、目的和文件路径;已覆盖的条件与缺口见 [覆盖清单](./COVERAGE.md)。下表用于选择相关范围,交互动作仍放在所属功能目录。 + +| 目录 | 主要覆盖 | +| --- | --- | +| `table`、`header`、`cells` | 列表基础、表头层级、单元格类型与格式 | +| `pivot`、`pivot-chart` | 透视维度、虚拟节点、透视图 | +| `gantt`、`sheet`、`plugins` | 扩展包的图形与 API | +| `layout`、`frozen`、`scroll`、`empty`、`theme` | 尺寸、冻结、滚动、空状态、主题 | +| `interaction`、`keyboard`、`edit`、`sort`、`menu` | 选择、编辑、排序 | +| `analysis`、`records`、`transpose` | 聚合、过滤、记录更新、转置 | +| `components`、`custom`、`language`、`tree`、`data`、`group`、`style`、`api` | 标题、自定义布局、多语言 | + +新增用例先查找已有近邻,优先在一个明确场景中补强必要条件。模块默认导出 `mount(container)`、`verify(page)` 和可选 `exercise(page)`;`mount` 返回待测实例。输入必须固定、公开、无外网依赖。`exercise` 需要执行真实动作,`verify` 需要检查目标状态;截图负责外观,不能替代语义断言。模块和核心方法写中文目的注释。登记唯一短 ID、目的、文件及所需 `bundles`;未登记模块不会执行。 + +只有从 BugServer 现有 case 迁移的模块才在头部写 `BugServer case IDs: <真实ID>`;合并时列出全部真实来源 ID,改写时仍保留原 ID。开发者或编码 Agent 独立新增的本地用例完全省略这一行,不填空值、占位或虚构 ID;未来同步成功后才补写服务返回的真实 ID。该 ID 只用于追溯,不表示与线上内容实时同步。 + +从 BugServer 迁移来源时,缺少 `interactions` 字段仅表示没有附加录制动作;仍须检查源码中的事件、定时器、异步调用、后续 API 更新及资源。保留目标配置、数据边界、动作顺序和最终状态。不得复制业务原文、内部地址、凭证、原始数据或截图到公开仓库;来源不明时先在仓库外私有目录复核。 + +新增或修改后先运行单例 `--self-compare --case `,再运行对应目录;提交前运行默认全量比较。若用例在官方基线不支持,报告为执行错误,应如实说明,不能把自比较当作双侧通过。 diff --git a/packages/vtable/__tests__/visual/cases/analysis/aggregation-float.mjs b/packages/vtable/__tests__/visual/cases/analysis/aggregation-float.mjs new file mode 100644 index 0000000000..5a7d8628d2 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/analysis/aggregation-float.mjs @@ -0,0 +1,26 @@ +/** + * BugServer case IDs: 69d89a775bd8c7005e123da1 + * 验证目的:小数数据的 AVG 与顶部 SUM 聚合在同一列表中显示。 + * 改写:原例的未声明变量和错误释放调用改为直接返回实例。 + */ +export default { + mount(container) { + // 用 0.1 与 0.2 触发浮点聚合,保留顶部和底部聚合位置。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'value', title: 'Value', width: 120, aggregation: [ + { aggregationType: window.VTable.TYPES.AggregationType.AVG }, + { aggregationType: window.VTable.TYPES.AggregationType.SUM, showOnTop: true } + ] }], + records: [{ value: 0.1 }, { value: 0.2 }], + bottomFrozenRowCount: 3, widthMode: 'autoWidth', heightMode: 'autoHeight', autoWrapText: true + }); + }, + async verify(page) { + // 小数记录和聚合行均存在,外观及聚合文本交给截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount < 5 || table.getCellOriginValue(0, 2) !== 0.1) + throw new Error('聚合行或小数记录缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/analysis/aggregation-pagination.mjs b/packages/vtable/__tests__/visual/cases/analysis/aggregation-pagination.mjs new file mode 100644 index 0000000000..8ae4857333 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/analysis/aggregation-pagination.mjs @@ -0,0 +1,35 @@ +/** + * BugServer case IDs: 65ed9e7aa5483e00afa5869c + * 验证目的:同值合并、SUM 聚合和每页两条记录的分页共同显示。 + * 改写:商品名称换成通用项,保留来源的 100/1/1/2/2/2 数值关系。 + */ +export default { + mount(container) { + // 固定六条记录,第一页包含 100 与 1,聚合总和可见。 + return new window.VTable.ListTable(container, { + records: [100, 1, 1, 2, 2, 2].map((price, index) => ({ area: 'A', product: `Item ${index + 1}`, price })), + columns: [ + { field: 'area', title: 'Area', width: 'auto', mergeCell: true, + aggregation: [{ aggregationType: window.VTable.TYPES.AggregationType.NONE, formatFun: () => 'Total' }] }, + { field: 'product', title: 'Product', width: 'auto' }, + { field: 'price', title: 'Price', width: 'auto', + aggregation: [{ aggregationType: window.VTable.TYPES.AggregationType.SUM, + formatFun: value => Math.round(value) }] } + ], + widthMode: 'standard', pagination: { perPageCount: 2, currentPage: 0 } + }); + }, + async verify(page) { + // 检查分页数据与数值聚合在可见表格中出现。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellValue(col, row)); + if (!values.includes('Item 1') || !values.includes('Item 2') || values.includes('Item 3')) + throw new Error('分页记录错误'); + if (!values.some(value => Number(value) === 101 || Number(value) === 108)) + throw new Error('SUM 聚合缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/analysis/filter-api.mjs b/packages/vtable/__tests__/visual/cases/analysis/filter-api.mjs new file mode 100644 index 0000000000..3e6013130c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/analysis/filter-api.mjs @@ -0,0 +1,27 @@ +/** + * BugServer case IDs: 65bf7a3197cc3d008de5b474 + * 验证目的:字段枚举筛选和函数筛选同时应用于列表。 + * 改写:用通用编号和状态替换人员信息,并缩小数据量以便本地稳定运行。 + */ +export default { + mount(container) { + // 60 条固定记录的交集是 10 条,便于直接验证筛选结果。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, { field: 'state', title: 'State', width: 120 }, + { field: 'name', title: 'Name', width: 150 }], + records: Array.from({ length: 60 }, (_, i) => ({ id: i + 1, state: i % 2 === 0 ? 'A' : 'B', name: `Item ${i + 1}` })), + frozenColCount: 1, widthMode: 'standard' + }); + table.updateFilterRules([{ filterKey: 'state', filteredValues: ['A'] }, + { filterFunc: record => record.id % 3 === 0 }]); + return table; + }, + async verify(page) { + // 两个过滤条件应同时生效,且首条可见记录符合交集。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount !== 11 || table.getCellValue(0, 1) !== 3) + throw new Error(`筛选结果错误:${table.rowCount}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/api/change-cell-values.mjs b/packages/vtable/__tests__/visual/cases/api/change-cell-values.mjs new file mode 100644 index 0000000000..3dc2b94183 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/api/change-cell-values.mjs @@ -0,0 +1,44 @@ +/** + * 验证目的:匿名二维批量单元格更新保持行列映射,并让相邻单元格不变。 + * 来源:独立 API 覆盖用例;含个人信息的 BugServer 来源已排除,不建立来源 ID 关联。 + */ +export default { + mount(container) { + // 用固定的通用字段和数值建立可观察的二维更新范围。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'id', title: 'ID', width: 100 }, + { field: 'amount', title: 'Amount', width: 120 }, + { field: 'state', title: 'State', width: 120 } + ], + records: [ + { id: 1, amount: 10, state: 'new' }, + { id: 2, amount: 20, state: 'new' }, + { id: 3, amount: 30, state: 'new' } + ] + }); + }, + async exercise(page) { + // 从第二列首条记录开始更新两行两列,并检查 API 的逐格成功结果。 + await page.evaluate(async () => { + const changed = await window.__visualTable.changeCellValues(1, 1, [[11, 'ready'], [21, 'hold']]); + if (changed.length !== 2 || changed.some(row => row.length !== 2 || row.some(value => value !== true))) + throw new Error('批量更新结果不完整'); + }); + }, + async verify(page) { + // 断言二维目标、原始记录和未触及的邻格均保持预期值。 + await page.evaluate(() => { + const table = window.__visualTable; + const expected = [[11, 'ready'], [21, 'hold']]; + for (let row = 0; row < expected.length; row++) { + for (let col = 0; col < expected[row].length; col++) { + if (table.getCellOriginValue(col + 1, row + 1) !== expected[row][col]) + throw new Error('批量更新的行列映射错误'); + } + } + if (table.getCellOriginValue(0, 1) !== 1 || table.getCellOriginValue(1, 3) !== 30) + throw new Error('批量更新越过目标范围'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/api/update-columns-after-selection.mjs b/packages/vtable/__tests__/visual/cases/api/update-columns-after-selection.mjs new file mode 100644 index 0000000000..eda0b8f5b5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/api/update-columns-after-selection.mjs @@ -0,0 +1,43 @@ +/** + * BugServer case IDs: 65af6e6906085f008cf8f85f + * 验证目的:点击选中单元格的回调内调用 updateColumns 后,选中状态与内容仍有效。 + * 改写:匿名固定记录保留点击触发更新的时机,用新标题使更新可观察。 + */ +export default { + mount(container) { + // 一次点击触发一次相同结构列的重新配置。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 110 }, { field: 'name', title: 'Name', width: 150 }], + records: [{ progress: 20, id: 1, name: 'A' }, { progress: 40, id: 2, name: 'B' }] + }); + window.__selectedColumnUpdates = 0; + table.on('click_cell', () => { + // 在点击事件同步调用 updateColumns,保留来源的重入路径。 + if (window.__selectedColumnUpdates++) return; + table.updateColumns([{ field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 110 }, { field: 'name', title: 'Name updated', width: 150 }]); + }); + return table; + }, + async exercise(page) { + // 实际点击第二列数据格,不通过 selectCell API 注入选中状态。 + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(1, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + await page.waitForFunction(() => window.__selectedColumnUpdates > 0); + }, + async verify(page) { + // 点击时列结构重建完成,选中范围与新增标题同时保持。 + await page.evaluate(() => { + const table = window.__visualTable; + const range = table.getSelectedCellRanges()[0]; + if (window.__selectedColumnUpdates < 1 || table.colCount !== 3 || + table.getCellValue(2, 0) !== 'Name updated' || !range || range.start.col !== 1) + throw new Error(`点击更新后的选择状态异常:${JSON.stringify(range)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/api/update-columns-on-mouseleave.mjs b/packages/vtable/__tests__/visual/cases/api/update-columns-on-mouseleave.mjs new file mode 100644 index 0000000000..87d767fd52 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/api/update-columns-on-mouseleave.mjs @@ -0,0 +1,42 @@ +/** + * BugServer case IDs: 65d45c7a97cc3d008de5b576 + * 验证目的:鼠标离开单元格回调中更新列结构后表格继续正常绘制。 + * 改写:使用匿名记录,保留 mouseleave_cell 中 updateColumns 的调用时机。 + */ +export default { + mount(container) { + // 多行记录提供真实的单元格离开事件;回调只运行一次。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 110 }, + { field: 'name', title: 'Name', width: 180 }, { field: 'value', title: 'Value', width: 130 }], + records: [{ id: 1, name: 'A', value: 10 }, { id: 2, name: 'B', value: 20 }, + { id: 3, name: 'C', value: 30 }] + }); + window.__mouseleaveUpdates = 0; + table.on('mouseleave_cell', () => { + // 事件回调里重建列是本例要验证的路径。 + if (window.__mouseleaveUpdates++) return; + table.updateColumns([{ field: 'name', title: 'Name', width: 180 }]); + }); + return table; + }, + async exercise(page) { + // 从首个数据格移出到表格外侧,触发真实 mouseleave_cell。 + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(0, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.move(point.x, point.y); + await page.mouse.move(850, 550); + await page.waitForFunction(() => window.__mouseleaveUpdates > 0); + }, + async verify(page) { + // 更新后只保留 Name 列,原始记录仍可渲染。 + await page.evaluate(() => { + const table = window.__visualTable; + if (window.__mouseleaveUpdates < 1 || table.colCount !== 1 || table.getCellValue(0, 1) !== 'A') + throw new Error('鼠标离开后列更新失败'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/api/update-columns.mjs b/packages/vtable/__tests__/visual/cases/api/update-columns.mjs new file mode 100644 index 0000000000..88eb7aa6ad --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/api/update-columns.mjs @@ -0,0 +1,25 @@ +/** + * BugServer case IDs: 65656abf7bb49c240fa99c78 + * 验证目的:悬停状态存在时 updateColumns 增加重复字段列。 + * 改写:展示字段换成通用项,保留更新时机与新增列结构。 + */ +export default { + mount(container) { + // 先设置悬停位置,再追加第二列同字段 Name。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 100 }, { field: 'name', title: 'Name', width: 150 }], + records: [{ progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }] + }); + table.stateManager.updateHoverPos(2, 2); + table.updateColumns([...table.columns, { field: 'name', title: 'Name copy', width: 150 }]); + return table; + }, + async verify(page) { + // 重复字段新列应正确读取原记录。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount !== 4 || table.getCellValue(3, 1) !== 'A') throw new Error('列更新失败'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/cells/checkbox-header-options.mjs b/packages/vtable/__tests__/visual/cases/cells/checkbox-header-options.mjs new file mode 100644 index 0000000000..6532f4d108 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/cells/checkbox-header-options.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 66f689cd33c1e900bc71e36a + * 验证目的:空字段、回调选中、固定选中和禁用配置的复选框表头共同绘制。 + * 改写:保留七种初始状态,其他人员字段改为通用编号。 + */ +export default { + mount(container) { + // 与来源相同地混合列级 headerType、cellType、checked 和 disable。 + return new window.VTable.ListTable(container, { + columns: [ + { field: '', title: '', headerType: 'checkbox', cellType: 'checkbox', width: 60 }, + { field: 'check', title: 'Check', headerType: 'checkbox', cellType: 'checkbox', width: 130, + checked: () => true }, + { field: 'progress', title: 'Progress', headerType: 'checkbox', cellType: 'checkbox', + checked: true, width: 150 }, + { field: 'id', title: 'ID', headerType: 'checkbox', cellType: 'checkbox', + checked: true, disable: true, width: 100 }, + { field: 'label', title: 'Label', width: 150 } + ], + records: [ + { check: { text: 'unchecked', checked: false, disable: false }, progress: 100, id: 1, label: 'A' }, + { check: { text: 'unchecked', checked: false, disable: true }, progress: 80, id: 2, label: 'B' }, + { check: { text: 'checked', checked: true, disable: false }, progress: 1, id: 3, label: 'C' }, + { check: { text: 'checked', checked: true, disable: true }, progress: 55, id: 4, label: 'D' }, + { check: true, progress: 28, id: 5, label: 'E' }, + { check: false, progress: 28, id: 6, label: 'F' }, + { progress: 28, id: 7, label: 'G' } + ], + frozenColCount: 2, allowFrozenColCount: 2, widthMode: 'standard' + }); + }, + async verify(page) { + // 七行、四类复选框列与冻结列必须共同存在,截图验证表头和禁用外观。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.frozenColCount !== 2 || table.colCount !== 5 || table.rowCount !== 8) + throw new Error('复选框列或数据行缺失'); + for (const col of [0, 1, 2, 3]) + if (table.getCellType(col, 1) !== 'checkbox') throw new Error('复选框列类型错误'); + if (table.getCellOriginValue(2, 1) !== 100 || table.getCellOriginValue(1, 7) !== undefined) + throw new Error('初始状态边界缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/cells/checkbox-over-thousand-rows.mjs b/packages/vtable/__tests__/visual/cases/cells/checkbox-over-thousand-rows.mjs new file mode 100644 index 0000000000..1eacdeebf1 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/cells/checkbox-over-thousand-rows.mjs @@ -0,0 +1,48 @@ +/** + * BugServer case IDs: 653b2f891053b2a37d5dc9b9 + * 验证目的:超过千行时滚动到远端后,冻结复选列仍可点击并保存状态。 + * 改写:重复固定匿名状态构建 2000 行,保留滚动和实际鼠标点击。 + */ +export default { + mount(container) { + // 五种复选状态重复 400 次,覆盖禁用、选中和未定义状态。 + const states = [ + { text: 'unchecked', checked: false, disable: false }, + { text: 'disabled', checked: false, disable: true }, + { text: 'checked', checked: true, disable: false }, + { text: 'locked', checked: true, disable: true }, + { text: 'unknown', disable: false } + ]; + const records = Array.from({ length: 2000 }, (_, index) => ({ + check: { ...states[index % states.length] }, name: `Item ${index + 1}` + })); + return new window.VTable.ListTable(container, { + columns: [{ field: '', title: 'All', headerType: 'checkbox', cellType: 'checkbox', width: 60 }, + { field: 'check', title: 'Check', headerType: 'checkbox', cellType: 'checkbox', width: 130 }, + { field: 'name', title: 'Name', width: 150 }], + records, defaultRowHeight: 32, frozenColCount: 2, allowFrozenColCount: 2 + }); + }, + async exercise(page) { + // 跳至第 1201 条记录并点击其未选中的冻结复选格。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + table.setScrollTop(1200 * 32); + const b = table.getCellRelativeRect(1, 1201).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + window.__farCheckboxBefore = table.getCellCheckboxState(1, 1201); + return { x: host.x + b.x1 + 20, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 远端目标状态由 false 变 true,同时维持 2000 条记录。 + await page.evaluate(() => { + const table = window.__visualTable; + if (window.__farCheckboxBefore !== false || table.getCellCheckboxState(1, 1201) !== true || + table.rowCount !== 2001 || table.getScrollTop() < 30000) + throw new Error(`千行复选格更新失败:${JSON.stringify({ before: window.__farCheckboxBefore, + after: table.getCellCheckboxState(1, 1201), rows: table.rowCount, scrollTop: table.getScrollTop() })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/cells/checkbox-states.mjs b/packages/vtable/__tests__/visual/cases/cells/checkbox-states.mjs new file mode 100644 index 0000000000..7b5516fded --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/cells/checkbox-states.mjs @@ -0,0 +1,35 @@ +/** + * BugServer case IDs: 653b222c1053b2a37d5dc9a7 + * 验证目的:复选框对象值的选中、禁用与布尔值状态正确初始化。 + * 改写:保留来源的状态组合,缩减无关的文本与排序列。 + */ +export default { + mount(container) { + // 覆盖未选、已选、禁用和直接布尔值四种输入形态。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'check', title: 'Check', width: 130, headerType: 'checkbox', cellType: 'checkbox' }, + { field: 'label', title: 'Label', width: 150 } + ], + records: [ + { check: { text: 'unchecked', checked: false, disable: false }, label: 'A' }, + { check: { text: 'unchecked', checked: false, disable: true }, label: 'B' }, + { check: { text: 'checked', checked: true, disable: false }, label: 'C' }, + { check: { text: 'checked', checked: true, disable: true }, label: 'D' }, + { check: true, label: 'E' }, { check: false, label: 'F' } + ], + frozenColCount: 1, widthMode: 'standard' + }); + }, + async verify(page) { + // 用公开状态 API 验证两种初始状态,而非只检查配置对象。 + await page.evaluate(() => { + const table = window.__visualTable; + const states = table.getCheckboxState('check'); + if (table.getCellType(0, 1) !== 'checkbox' || states.length !== 6) + throw new Error('复选框未初始化'); + if (table.getCellCheckboxState(0, 1) !== false || table.getCellCheckboxState(0, 3) !== true) + throw new Error('复选框初始状态错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/cells/inline-image.mjs b/packages/vtable/__tests__/visual/cases/cells/inline-image.mjs new file mode 100644 index 0000000000..bd54994637 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/cells/inline-image.mjs @@ -0,0 +1,35 @@ +/** + * BugServer case IDs: 64744ff55d221c008666ee12 + * 验证目的:图片单元格自动尺寸、保持宽高比,点击图片边缘不报错。 + * 改写:将远程动物照片替换为本地内联图形,保留不同图片比例与录制点击。 + */ +export default { + mount(container) { + // 内联图形避免公开测试依赖远程图片服务。 + const svg = (width, height, color) => `data:image/svg+xml,${encodeURIComponent(``)}`; + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, + { field: 'image', title: 'Image', width: 300, cellType: 'image', keepAspectRatio: true, + imageAutoSizing: true, style: { padding: 1 } }], + records: [{ id: 1, image: svg(100, 50, '#75aadb') }, + { id: 2, image: svg(50, 100, '#e8af6f') }, { id: 3, image: svg(100, 100, '#82bd84') }] + }); + }, + async exercise(page) { + // 录制动作指向图片边框内侧,目标定位随当前单元格尺寸计算。 + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(1, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + b.x1 + 15, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 边框点击不应损坏图片字段或选择状态。 + await page.evaluate(() => { + const table = window.__visualTable; + if (!table.getCellValue(1, 1).startsWith('data:image/svg+xml')) throw new Error('图片字段缺失'); + if (!table.getSelectedCellRanges().length) throw new Error('图片边缘点击未选中单元格'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/cells/link-merged-auto-height.mjs b/packages/vtable/__tests__/visual/cases/cells/link-merged-auto-height.mjs new file mode 100644 index 0000000000..bcfd1b6d57 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/cells/link-merged-auto-height.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 6a8597d0a44900005e2b8e47 + * 验证目的:合并的链接单元格在自动行高下绘制,点击时不跳转。 + * 改写:把来源的长列表缩为三个匿名记录和公开示例地址。 + */ +export default { + mount(container) { + // 保留来源的 link、mergeCell、linkDetect、linkJump 组合。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'site', title: 'Link', width: 240, cellType: 'link', mergeCell: true, + linkDetect: true, linkJump: false }, + { field: 'value', title: 'Value', width: 120 } + ], + records: [ + { site: 'https://example.com/guide', value: 'A' }, + { site: 'https://example.com/guide', value: 'B' }, + { site: 'https://example.com/help', value: 'C' } + ], + heightMode: 'autoHeight', defaultRowHeight: 'auto' + }); + }, + async exercise(page) { + // 点击首个链接,确认 linkJump:false 不会离开本地测试页。 + const before = page.url(); + const point = await page.evaluate(() => { + const bounds = window.__visualTable.getCellRect(0, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + bounds.x1 + 35, y: host.y + bounds.y1 + 12 }; + }); + await page.mouse.click(point.x, point.y); + if (page.url() !== before) throw new Error('禁用跳转的链接离开了测试页'); + await page.mouse.move(760, 560); + }, + async verify(page) { + // 单元格类型与合并范围都必须由运行时布局实际识别。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellType(0, 1) !== 'link') throw new Error('链接类型未生效'); + if (table.getCellOriginValue(0, 1) !== 'https://example.com/guide') + throw new Error('链接值不正确'); + const range = table.getCellRange(0, 1); + if (range.start.row !== 1 || range.end.row !== 2) + throw new Error('连续链接单元格未合并'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/cells/mixed-sparkline-progress.mjs b/packages/vtable/__tests__/visual/cases/cells/mixed-sparkline-progress.mjs new file mode 100644 index 0000000000..a6f83ef550 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/cells/mixed-sparkline-progress.mjs @@ -0,0 +1,34 @@ +/** + * BugServer case IDs: 65d2ffbb97cc3d008de5b570, 65fc11f6603de700d1d8bacf, 65fbd5707c3a0c00c99f2eb9 + * 验证目的:同一列按行切换 sparkline、progressbar 与文本,负值进度条仍显示。 + * 改写:缩小颜色定义与数据量,合并两例 row-height;保留行类型分支、负值范围与自动行高。 + */ +export default { + mount(container) { + // 记录顺序对应来源的 1/2/3 行类型分支。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'percent', title: 'Percent', width: 150, + cellType: args => args.row % 3 === 0 ? 'text' : args.row % 2 === 0 ? 'progressbar' : 'sparkline', + sparklineSpec: { type: 'line', smooth: true, pointShowRule: 'all', line: { style: { stroke: '#2E62F1', strokeWidth: 2 } } } }, + { field: 'value', title: 'Axis', width: 120, cellType: 'progressbar', min: -10, max: 20, + barType: 'negative', style: { barHeight: 20, barBottom: 7, textAlign: 'right' } } + ], + records: [ + { percent: [30, 40, 50, 30, 50], value: 20 }, + { percent: '80%', value: 18 }, + { percent: 'Text', value: -10 } + ], + widthMode: 'standard', heightMode: 'autoHeight' + }); + }, + async verify(page) { + // 检查动态类型真正按行分配,并确认负值数据未丢失。 + await page.evaluate(() => { + const table = window.__visualTable; + const types = [1, 2, 3].map(row => table.getCellType(0, row)); + if (types.join(',') !== 'sparkline,progressbar,text') throw new Error(`行类型错误:${types}`); + if (table.getCellOriginValue(1, 3) !== -10) throw new Error('负值记录缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/cells/switch-button.mjs b/packages/vtable/__tests__/visual/cases/cells/switch-button.mjs new file mode 100644 index 0000000000..ac25afd9a8 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/cells/switch-button.mjs @@ -0,0 +1,34 @@ +/** + * BugServer case IDs: 67b823fc88790a00ae624ae3, 67b829a64d4ded00b2f12762 + * 验证目的:开关与按钮列同时覆盖普通、默认选中和禁用视觉状态。 + * 改写:合并两个使用相同记录结构的示例,保留各自的核心列配置。 + */ +export default { + mount(container) { + // 保留对象值开关和列级 checked/disable 两类来源条件。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'value', title: 'Value', width: 100 }, + { field: 'switch', title: 'Switch', width: 110, cellType: 'switch', checkedText: 'on', uncheckedText: 'off' }, + { field: 'switch1', title: 'Checked', width: 110, cellType: 'switch', checked: true, checkedText: 'on', uncheckedText: 'off' }, + { field: 'switch2', title: 'Disabled', width: 110, cellType: 'switch', checked: true, disable: true }, + { field: 'button', title: 'Button', width: 110, cellType: 'button', text: 'click' }, + { field: 'button', title: 'Disabled button', width: 130, cellType: 'button', disable: true, text: 'click' } + ], + records: [ + { value: 20 }, { value: 18 }, { value: 12, switch: { checked: true, disable: true } }, + { value: 10 }, { value: -10 } + ], + widthMode: 'standard', defaultRowHeight: 80, heightMode: 'autoHeight' + }); + }, + async verify(page) { + // 验证渲染类型和来源对象的选中状态,其他外观由截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellType(1, 3) !== 'switch' || table.getCellType(4, 3) !== 'button') + throw new Error('开关或按钮列类型错误'); + if (table.getCellSwitchState(1, 3) !== true) throw new Error('开关状态错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/cells/wrapped-text.mjs b/packages/vtable/__tests__/visual/cases/cells/wrapped-text.mjs new file mode 100644 index 0000000000..03d4290c5c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/cells/wrapped-text.mjs @@ -0,0 +1,31 @@ +/** + * BugServer case IDs: 64744f315d221c008666ee10 + * 验证目的:字段格式化后的长文本和显式换行在自动行高下显示。 + * 改写:删除与换行无关的排序回调,保留格式化、autoWrapText 和 enableLineBreak。 + */ +export default { + mount(container) { + // 固定多行记录,使长文本及显式换行都进入可见区域。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, style: { autoWrapText: true }, + fieldFormat: record => `Completed completed completed ${record.progress}%` }, + { field: 'id', title: 'ID', width: 150, + fieldFormat: record => `Line one\nLine two ${record.id}` }, + { field: 'name', title: 'Name', width: 150 } + ], + records: Array.from({ length: 10 }, (_, index) => ({ progress: index, id: index + 1, name: 'name' })), + widthMode: 'standard', heightMode: 'autoHeight', defaultRowHeight: 50, + autoWrapText: true, enableLineBreak: true + }); + }, + async verify(page) { + // 同时检查格式化文字与表格的有效行高。 + await page.evaluate(() => { + const table = window.__visualTable; + if (!String(table.getCellValue(0, 1)).includes('Completed')) throw new Error('格式化文本缺失'); + if (!String(table.getCellValue(1, 1)).includes('\n')) throw new Error('显式换行缺失'); + if (table.getRowHeight(1) < 50) throw new Error('自动行高未生效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/components/hover-tooltip.mjs b/packages/vtable/__tests__/visual/cases/components/hover-tooltip.mjs new file mode 100644 index 0000000000..29c637adee --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/components/hover-tooltip.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 64f81f827d8f46008b2df45e + * 验证目的:鼠标依次经过两个进度条单元格时,showTooltip 更新内容。 + * 改写:缩短动态图标和数据,保留 mouseenter_cell、showTooltip 与两次悬停。 + */ +export default { + mount(container) { + // 事件处理使用当前单元格值,第二次悬停必须替换第一次内容。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'value', title: 'Value', width: 120 }, + { field: 'percent', title: 'Progress', width: 160, cellType: 'progressbar' }], + records: [{ value: 20, percent: '80%' }, { value: 10, percent: '40%' }] + }); + table.on('mouseenter_cell', ({ col, row }) => { + const rect = table.getVisibleCellRangeRelativeRect({ col, row }); + table.showTooltip(col, row, { content: `cell data: ${table.getCellValue(col, row)}`, + referencePosition: { rect, placement: window.VTable.TYPES.Placement.right }, + className: 'visual-tooltip', style: { bgColor: 'black', color: 'white', arrowMark: true } }); + }); + return table; + }, + async exercise(page) { + // 两个坐标都来自表格矩形,分别断言悬停后真实浮层文本。 + for (const row of [1, 2]) { + const point = await page.evaluate(row => { + const b = window.__visualTable.getCellRect(0, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, row); + await page.mouse.move(point.x, point.y); + await page.waitForFunction(value => document.body.innerText.includes(`cell data: ${value}`), row === 1 ? 20 : 10); + } + }, + async verify(page) { + // 最终提示应显示第二行的值。 + await page.evaluate(() => { + if (!document.body.innerText.includes('cell data: 10')) throw new Error('提示内容未更新'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/components/legend-resize.mjs b/packages/vtable/__tests__/visual/cases/components/legend-resize.mjs new file mode 100644 index 0000000000..30ffba7757 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/components/legend-resize.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 672b4d9ae4bb4400b299cbce + * 验证目的:顶部单行图例与标题共存时,宿主缩窄后组件继续正常布局。 + * 改写:图例标签改为通用类别,保留 maxRow、标题和 800 到 300 像素变宽过程。 + */ +export default { + mount(container) { + // 宿主先以宽布局挂载,然后模拟来源中的窄容器更新。 + container.style.width = '800px'; + container.style.height = '400px'; + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'count', title: 'Count', width: 150 }, + { field: 'id', title: 'ID', width: 100 }], + records: [{ count: 10, id: 1 }, { count: 20, id: 2 }], + legends: { data: ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta'].map((label, i) => + ({ label, shape: { fill: ['#2E62F1', '#4DC36A', '#FF8406'][i % 3], symbolType: 'circle' } })), + orient: 'top', position: 'start', maxRow: 1, padding: [50, 0, 0, 0] }, + title: { text: 'Legend Resize', align: 'center', subtext: 'Narrow container', orient: 'top', padding: 40 } + }); + container.style.width = '300px'; + container.style.height = '300px'; + return table; + }, + async verify(page) { + // 缩窄后宿主宽度和末条数据均有效;组件布局由截图判断。 + await page.evaluate(() => { + const table = window.__visualTable; + if (document.getElementById('table').getBoundingClientRect().width !== 300 || + table.getCellValue(0, 2) !== 20) + throw new Error('图例缩窄场景无效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/components/title.mjs b/packages/vtable/__tests__/visual/cases/components/title.mjs new file mode 100644 index 0000000000..8e2cd8ced4 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/components/title.mjs @@ -0,0 +1,39 @@ +/** + * BugServer case IDs: 64ba680378943290ce95d080 + * 验证目的:顶部标题、副标题和表格表头布局正确,录制的表头点击可用。 + * 改写:标题和记录换成通用内容,点击点由表头矩形计算。 + */ +export default { + mount(container) { + // 保留标题顶部方向、内边距与换行副标题。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true, + fieldFormat: record => `${record.progress}%` }, + { field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 100, id: 1, name: 'A' }, { progress: 50, id: 2, name: 'B' }], + title: { text: 'Table title', align: 'left', subtext: 'Subtitle line 1\nSubtitle line 2', orient: 'top', padding: 40 }, + widthMode: 'standard' + }); + }, + async exercise(page) { + // 按录制动作点击首列表头,但使用当前布局的矩形坐标。 + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(0, 0).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 标题占位和首列内容均应在表格中。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 1) !== '100%') throw new Error('标题下表格内容缺失'); + if (!table.internalProps.title?.getComponentGraphic()?.AABBBounds.height() || table.tableY <= 0) + throw new Error('标题未占顶部空间'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/custom/progress-layout.mjs b/packages/vtable/__tests__/visual/cases/custom/progress-layout.mjs new file mode 100644 index 0000000000..b34b6ca30c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/custom/progress-layout.mjs @@ -0,0 +1,42 @@ +/** + * BugServer case IDs: 67ac6b4ddb3b0800b20431e5 + * 验证目的:动态图标、分段进度条、负值进度条与自定义布局共同渲染。 + * 改写:将长 SVG 缩成内联箭头,保留回调结构与不同进度值。 + */ +export default { + mount(container) { + // 用固定记录触发所有颜色分支和自定义布局回调。 + window.__customLayoutCalls = 0; + const VTable = window.VTable; + return new VTable.ListTable(container, { + columns: [ + { field: 'value', title: 'Icon', width: 90, + style: { color: data => Number(data.value) > 0 ? '#50aa53' : '#d85859' }, + icon: () => ({ type: 'svg', svg: '', width: 12, height: 12, name: 'arrow', positionType: VTable.TYPES.IconPosition.right }) }, + { field: 'percent', title: 'Progress', width: 140, cellType: 'progressbar', + style: { barHeight: 20, barColor: data => Number.parseInt(data.dataValue, 10) > 50 ? '#4dbd74' : '#ffc107' } }, + { field: 'percent', title: 'Custom', width: 180, cellType: 'progressbar', + customLayout: ({ table, col, row, rect }) => { + window.__customLayoutCalls++; + const record = table.getRecordByCell(col, row); + const bounds = rect ?? table.getCellRect(col, row); + const rootContainer = new VTable.CustomLayout.Container({ width: bounds.width, height: bounds.height }); + rootContainer.add(new VTable.CustomLayout.Text({ text: `${record.percent} : ${record.value}`, fontSize: 13, fill: 'black', marginLeft: 10 })); + return { rootContainer }; + } }, + { field: 'value', title: 'Negative axis', width: 140, cellType: 'progressbar', min: -10, max: 20, + barType: 'negative', style: { barHeight: 20, barBottom: 7, textAlign: 'right' } } + ], + records: [{ percent: '100%', value: 20 }, { percent: '60%', value: 12 }, + { percent: '20%', value: 4 }, { percent: '0%', value: -10 }], + showFrozenIcon: true, allowFrozenColCount: 2, widthMode: 'standard' + }); + }, + async verify(page) { + // 自定义布局必须执行,负值记录必须保留。 + await page.evaluate(() => { + if (window.__customLayoutCalls < 4) throw new Error('自定义布局未执行'); + if (window.__visualTable.getCellOriginValue(3, 4) !== -10) throw new Error('负值进度条记录缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/data/nested-field.mjs b/packages/vtable/__tests__/visual/cases/data/nested-field.mjs new file mode 100644 index 0000000000..f024caa257 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/data/nested-field.mjs @@ -0,0 +1,26 @@ +/** + * BugServer case IDs: 6720b9ea30877400b3e630d7 + * 验证目的:数组形式字段路径读取嵌套值,缺失路径不使表格失败。 + * 改写:将文字改为通用项,保留第三条缺失嵌套对象。 + */ +export default { + mount(container) { + // 第三条不包含 type,验证字段路径的缺失值分支。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Name', width: 120, sort: true }, + { field: 'age', title: 'Age', width: 100, sort: true }, + { field: ['type', 'name'], title: 'Type', width: 130, sort: true }], + records: [{ name: 'A', age: 12, type: { name: 'One' } }, + { name: 'B', age: 42, type: { name: 'Two' } }, { name: 'C', age: 42 }], + widthMode: 'standard' + }); + }, + async verify(page) { + // 有值与缺值两条路径都应返回预期结果。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(2, 1) !== 'One' || table.getCellValue(2, 3)) + throw new Error('嵌套字段读取错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/data/promise-custom-merge.mjs b/packages/vtable/__tests__/visual/cases/data/promise-custom-merge.mjs new file mode 100644 index 0000000000..e83e9bed3c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/data/promise-custom-merge.mjs @@ -0,0 +1,50 @@ +/** + * BugServer case IDs: 65671306eb24adef19a3a669 + * 验证目的:CachedDataSource 异步批次加载与多行多列 customMergeCell 同时生效。 + * 改写:移除人员联系方式及职业信息,保留 Promise 缓存、合并区域和滚动位置。 + */ +export default { + mount(container) { + // 按批次缓存同一个 Promise,固定数据避免远程请求与时间波动。 + const batches = new Map(); + const source = new window.VTable.data.CachedDataSource({ + get(index) { + // 每 20 条记录共用一次异步生成,模拟原始来源的分页获取。 + const start = Math.floor(index / 20) * 20; + if (!batches.has(start)) batches.set(start, new Promise(resolve => { + setTimeout(() => resolve(Array.from({ length: 20 }, (_, offset) => ({ + id: start + offset + 1, label: `Item ${start + offset + 1}`, + group: `Group ${(start + offset) % 3 + 1}`, date: '2024-01-01' + }))), 30); + })); + return batches.get(start).then(records => records[index - start]); + }, + length: 200 + }); + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, + { field: 'label', title: 'Label', width: 170 }, + { field: 'group', title: 'Group', width: 170 }, + { field: 'date', title: 'Date', width: 150 }], + customMergeCell: (col, row) => { + // 第 8-10 行的三列使用与 BugServer 来源相同的合并边界。 + if (col < 1 || col > 3 || row < 8 || row > 10) return undefined; + return { text: 'Merged async rows', range: { start: { col: 1, row: 8 }, + end: { col: 3, row: 10 } }, style: { bgColor: '#ccddee' } }; + } + }); + table.dataSource = source; + table.setScrollTop(60); + return table; + }, + async verify(page) { + // 等待异步批次完成,并确认合并区域在可见行内有效。 + await page.waitForFunction(() => window.__visualTable.getCellValue(0, 8) === 8); + await page.evaluate(() => { + const table = window.__visualTable; + const merge = table.getCellRange(1, 8); + if (merge?.start?.col !== 1 || merge?.end?.col !== 3 || merge?.end?.row !== 10) + throw new Error(`异步合并区域错误:${JSON.stringify(merge)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/data/promise-keep-data.mjs b/packages/vtable/__tests__/visual/cases/data/promise-keep-data.mjs new file mode 100644 index 0000000000..7e351f2891 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/data/promise-keep-data.mjs @@ -0,0 +1,39 @@ +/** + * BugServer case IDs: 6806172028b4c200a9da9987 + * 验证目的:CachedDataSource 挂载后 updateOption({keepData:true}) 保留异步数据。 + * 改写:去除邮箱、电话和人员字段,改用同步可重复的批次 Promise。 + */ +export default { + mount(container) { + // 同一缓存批次为相邻行返回稳定记录。 + const source = new window.VTable.data.CachedDataSource({ + length: 20, + get(index) { + // 每次请求按索引返回同一条固定记录。 + return Promise.resolve({ id: index + 1, label: `Item ${index + 1}` }); + } + }); + const columns = [{ field: 'id', title: 'ID', width: 100 }, + { field: 'label', title: 'Label', width: 180 }]; + const table = new window.VTable.ListTable(container, { columns }); + table.dataSource = source; + window.__keepDataColumns = columns; + return table; + }, + async exercise(page) { + // 首行数据加载后更新列配置,复用既有数据源。 + await page.waitForFunction(() => window.__visualTable.getCellValue(0, 1) === 1); + await page.evaluate(() => { + window.__visualTable.updateOption({ columns: window.__keepDataColumns }, { keepData: true }); + }); + }, + async verify(page) { + // 更新后仍能读取首行与后续异步行。 + await page.waitForFunction(() => window.__visualTable.getCellValue(0, 2) === 2); + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(1, 1) !== 'Item 1' || table.getCellValue(1, 2) !== 'Item 2') + throw new Error('updateOption 丢失 CachedDataSource 数据'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/data/promise-source.mjs b/packages/vtable/__tests__/visual/cases/data/promise-source.mjs new file mode 100644 index 0000000000..2d7ca6a023 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/data/promise-source.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 6565ca7e4c6c1a77d0efcc76 + * 验证目的:CachedDataSource 的异步 get 按批次加载可见行。 + * 改写:用固定编号代替人员信息,缩短模拟请求等待;保留 Promise 与批次缓存。 + */ +export default { + mount(container) { + // 每 20 行只创建一个 Promise,模拟来源的分批异步数据。 + const loaded = new Map(); + const source = new window.VTable.data.CachedDataSource({ + get(index) { + const start = Math.floor(index / 20) * 20; + if (!loaded.has(start)) loaded.set(start, new Promise(resolve => { + setTimeout(() => resolve(Array.from({ length: 20 }, (_, offset) => ({ id: start + offset + 1, name: `Item ${start + offset + 1}` }))), 30); + })); + return loaded.get(start).then(records => records[index - start]); + }, + length: 100 + }); + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 120 }, { field: 'name', title: 'Name', width: 180 }] + }); + table.dataSource = source; + return table; + }, + async verify(page) { + // 等待异步首批数据进入画布,不能以固定 sleep 代替。 + await page.waitForFunction(() => window.__visualTable.getCellValue(0, 1) === 1); + await page.evaluate(() => { + if (window.__visualTable.getCellValue(1, 1) !== 'Item 1') throw new Error('异步数据未加载'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/edit/complete-input.mjs b/packages/vtable/__tests__/visual/cases/edit/complete-input.mjs new file mode 100644 index 0000000000..9d1070f060 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/edit/complete-input.mjs @@ -0,0 +1,35 @@ +/** + * BugServer case IDs: 656856014c3611faee9b1a5c + * 验证目的:输入编辑器写入新值并调用 completeEditCell 后记录被更新。 + * 改写:本地加载编辑器,保留来源通过 getEditor.element 修改值的路径。 + */ +export default { + mount(container) { + // 第二条记录的 id 初始缺失,编辑完成后应成为明确的新值。 + const VTable = window.VTable; + VTable.register.editor('input-editor', new VTable.editors.InputEditor({})); + const table = new VTable.ListTable(container, { + columns: [ + { field: 'name', title: 'Name', width: 150 }, + { field: 'date', title: 'Date', width: 150 }, + { field: 'id', title: 'ID', width: 100, editor: 'input-editor' } + ], + records: [{ name: 'A', date: '2019-01-01', id: 1 }, + { name: 'B', date: '2010-11-01' }, { name: 'C', date: '2013-10-20', id: 3 }] + }); + table.startEditCell(2, 2); + const editor = table.getEditor(2, 2); + if (!editor?.element) throw new Error('输入编辑器没有挂载'); + editor.element.value = '11111'; + table.completeEditCell(); + return table; + }, + async verify(page) { + // 检查真实记录值,防止只在编辑框中显示新值而未提交。 + await page.evaluate(() => { + const table = window.__visualTable; + if (String(table.getCellOriginValue(2, 2)) !== '11111') + throw new Error('编辑值未提交'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/edit/start-with-value.mjs b/packages/vtable/__tests__/visual/cases/edit/start-with-value.mjs new file mode 100644 index 0000000000..8f6bb879ad --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/edit/start-with-value.mjs @@ -0,0 +1,38 @@ +/** + * BugServer case IDs: 66960d6749841700ac7664d4 + * 验证目的:输入编辑器通过 startEditCell 的初始值参数打开并显示该值。 + * 改写:编辑器从本地 bundle 加载,删除带时间戳的内网 CDN 请求。 + */ +export default { + mount(container) { + // 本地注册与来源相同的输入和日期编辑器,再用第三列第二行启动编辑。 + const VTable = window.VTable; + VTable.register.editor('input-editor', new VTable.editors.InputEditor({})); + VTable.register.editor('date-editor', new VTable.editors.DateInputEditor({})); + const table = new VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, editor: 'input-editor' }, + { field: 'birthday', title: 'Birthday', width: 150, editor: 'date-editor' }, + { field: 'id', title: 'ID', width: 100, editor: 'input-editor' }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [ + { progress: 100, id: 1, name: 'A', birthday: '2019-01-01' }, + { progress: 80, name: 'B', birthday: '2010-11-01' }, + { progress: 1, id: 3, name: 'C', birthday: '2013-10-20' } + ], + widthMode: 'standard', allowFrozenColCount: 2 + }); + table.startEditCell(2, 2, 'nihao'); + return table; + }, + async verify(page) { + // 确认实际编辑控件已挂载,并接收 startEditCell 指定的初始值。 + await page.evaluate(() => { + const table = window.__visualTable; + const editor = table.getEditor(2, 2); + if (!editor?.element || editor.element.value !== 'nihao') + throw new Error('输入编辑器初始值不正确'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/empty/list-resize.mjs b/packages/vtable/__tests__/visual/cases/empty/list-resize.mjs new file mode 100644 index 0000000000..8829995c36 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/empty/list-resize.mjs @@ -0,0 +1,27 @@ +/** + * BugServer case IDs: 66554c5a5a5b1400ab9139e9 + * 验证目的:空记录表在容器尺寸变化后保持表头与 emptyTip 绘制。 + * 改写:减少重复列数量,保留水平溢出、冻结列与创建后缩小宿主条件。 + */ +export default { + mount(container) { + // 先以 800x600 创建,再缩至来源的 500x300 条件。 + const columns = Array.from({ length: 8 }, (_, index) => ({ field: `field${index}`, title: `Column ${index}`, width: 150 })); + const table = new window.VTable.ListTable(container, { + columns, records: [], frozenColCount: 2, widthMode: 'standard', emptyTip: {} + }); + container.style.width = '500px'; + container.style.height = '300px'; + table.resize(); + return table; + }, + async verify(page) { + // 确认空数据和缩小后的视口均真实存在,通用绘制检查负责非空画布。 + await page.evaluate(() => { + const table = window.__visualTable; + const host = document.getElementById('table'); + if (table.records.length !== 0 || host.clientWidth !== 500 || host.clientHeight !== 300) + throw new Error('空表尺寸条件未生效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/empty/pivot-tip.mjs b/packages/vtable/__tests__/visual/cases/empty/pivot-tip.mjs new file mode 100644 index 0000000000..76e68c6bfe --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/empty/pivot-tip.mjs @@ -0,0 +1,22 @@ +/** + * BugServer case IDs: 66554d175a5b1400ab9139eb + * 验证目的:无记录透视表显示空状态并保留强制角头。 + * 改写:维度名称换成通用名称,保留两级行列及双指标。 + */ +export default { + mount(container) { + // 不传 records,检验空表布局分支。 + return new window.VTable.PivotTable(container, { + rows: ['group', 'item'], columns: ['kind', 'variant'], indicators: ['amount', 'count'], + enableDataAnalysis: true, indicatorsAsCol: false, + corner: { forceShowHeader: true, titleOnDimension: 'row' }, emptyTip: {}, widthMode: 'autoWidth' + }); + }, + async verify(page) { + // 角头必须可见,表格仍有有效布局尺寸。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount < 1 || table.colCount < 1) throw new Error('空透视表未布局'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/drag-adjust-count.mjs b/packages/vtable/__tests__/visual/cases/frozen/drag-adjust-count.mjs new file mode 100644 index 0000000000..5d330a4c58 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/drag-adjust-count.mjs @@ -0,0 +1,41 @@ +/** + * BugServer case IDs: 65ae4667366fb000953feeb4 + * 验证目的:adjustFrozenCount 模式将普通列拖入左冻结区时增加冻结列数。 + * 改写:来源的大数据和额外图标删除,只保留跨冻结边界拖动。 + */ +export default { + mount(container) { + // 前两列冻结,列 C 从非冻结区移入冻结区后应将冻结列数改为三。 + return new window.VTable.ListTable(container, { + columns: ['A', 'B', 'C', 'D', 'E'].map(field => ({ field, title: field, width: 120 })), + records: [{ A: 'A1', B: 'B1', C: 'C1', D: 'D1', E: 'E1' }], + dragHeaderMode: 'all', frozenColDragHeaderMode: 'adjustFrozenCount', + frozenColCount: 2, widthMode: 'standard' + }); + }, + async exercise(page) { + // 在真实表头上选中 C,再拖至 A 的左侧。 + const [from, to] = await page.evaluate(() => { + const table = window.__visualTable; + const host = document.getElementById('table').getBoundingClientRect(); + return [2, 0].map(col => { + const b = table.getCellRect(col, 0).bounds; + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + }); + await page.mouse.click(from.x, from.y); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 12 }); + await page.mouse.up(); + }, + async verify(page) { + // 顺序和冻结列数必须同步变化,避免只更新外观。 + await page.evaluate(() => { + const table = window.__visualTable; + const fields = [0, 1, 2, 3, 4].map(col => table.getHeaderField(col, 0)); + if (fields.join(',') !== 'C,A,B,D,E' || table.frozenColCount !== 3) + throw new Error(`拖入冻结区未调整列数:${JSON.stringify({ fields, count: table.frozenColCount })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/drag-disabled.mjs b/packages/vtable/__tests__/visual/cases/frozen/drag-disabled.mjs new file mode 100644 index 0000000000..1a3e0463b5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/drag-disabled.mjs @@ -0,0 +1,43 @@ +/** + * BugServer case IDs: 65ae4544366fb000953feeb1 + * 验证目的:disabled 模式禁止拖动冻结列表头,同时普通列仍可拖动。 + * 改写:来源的五千行人员记录和 SVG 去除,保留左右冻结及两次表头拖动。 + */ +export default { + mount(container) { + // 前两列和末列冻结,普通列 C、D 可作为对照进行真实拖动。 + return new window.VTable.ListTable(container, { + columns: ['A', 'B', 'C', 'D', 'E'].map(field => ({ field, title: field, width: 120 })), + records: [{ A: 'A1', B: 'B1', C: 'C1', D: 'D1', E: 'E1' }], + dragHeaderMode: 'all', frozenColDragHeaderMode: 'disabled', + frozenColCount: 2, rightFrozenColCount: 1, widthMode: 'standard' + }); + }, + async exercise(page) { + // 先尝试把冻结列 A 拖至 D,再将普通列 D 拖至 C。 + const points = await page.evaluate(() => { + const table = window.__visualTable; + const host = document.getElementById('table').getBoundingClientRect(); + return [0, 2, 3].map(col => { + const b = table.getCellRect(col, 0).bounds; + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + }); + for (const [from, to] of [[points[0], points[2]], [points[2], points[1]]]) { + await page.mouse.click(from.x, from.y); + await page.mouse.move(from.x, from.y); + await page.mouse.down(); + await page.mouse.move(to.x, to.y, { steps: 12 }); + await page.mouse.up(); + } + }, + async verify(page) { + // 冻结列仍在原位,普通两列交换,说明并非整个拖动模式失效。 + await page.evaluate(() => { + const table = window.__visualTable; + const fields = [0, 1, 2, 3, 4].map(col => table.getHeaderField(col, 0)); + if (fields.join(',') !== 'A,B,D,C,E' || table.frozenColCount !== 2) + throw new Error(`禁拖冻结列表头结果异常:${JSON.stringify(fields)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/empty-records-header.mjs b/packages/vtable/__tests__/visual/cases/frozen/empty-records-header.mjs new file mode 100644 index 0000000000..94063be81e --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/empty-records-header.mjs @@ -0,0 +1,53 @@ +/** + * BugServer case IDs: 6a82f7a71d0efd005e397e9d + * 验证目的:大量列横向滚到末端后,点击触发 setRecords([]) 不应清空冻结表头。 + * 改写:保留 311 列和双侧冻结边界,商品字段替换成编号与通用数值。 + */ +export default { + mount(container) { + // 311 列保留来源的横向虚拟化压力,六条匿名记录足以触发空记录更新。 + container.style.width = '900px'; + container.style.height = '420px'; + const columns = Array.from({ length: 311 }, (_, col) => ({ + field: 'c' + col, title: 'Field ' + col, width: col < 11 ? 'auto' : 120 + })); + const records = Array.from({ length: 6 }, (_, row) => + Object.fromEntries(columns.map((column, col) => [column.field, row * 1000 + col]))); + const table = new window.VTable.ListTable(container, { + columns, records, frozenRowCount: 3, bottomFrozenRowCount: 1, + frozenColCount: 2, rightFrozenColCount: 2, widthMode: 'standard', + rowSeriesNumber: { width: 50, format: () => '', cellType: 'checkbox', headerType: 'checkbox' } + }); + let triggered = false; + table.on('click_cell', () => { + // 保留来源的点击触发路径,只执行一次空记录更新。 + if (triggered) return; + triggered = true; + table.setScrollLeft(100000); + table.setRecords([]); + }); + return table; + }, + async exercise(page) { + // 先远距离横向滚动,再点击左冻结表体格触发 setRecords([])。 + await page.evaluate(() => window.__visualTable.setScrollLeft(100000)); + await page.waitForFunction(() => window.__visualTable.scrollLeft > 0); + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(1, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + await page.waitForFunction(() => window.__visualTable.records.length === 0); + }, + async verify(page) { + // 空记录更新后左右两侧表头仍有字段,截图检查中间表头绘制。 + await page.evaluate(() => { + const table = window.__visualTable; + const left = table.getCellValue(1, 0); + const right = table.getCellValue(table.colCount - 1, 0); + if (table.records.length !== 0 || !left || !right || table.colCount < 300) + throw new Error('空记录更新后冻结表头丢失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-end.mjs b/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-end.mjs new file mode 100644 index 0000000000..5525ee481c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-end.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 6aaba81349fc4f005c341b4d + * 验证目的:横向滚动到 end 时,冻结列阴影仅在存在溢出的一侧显示。 + * 改写:保留来源的 10 列宽度与 overflow 阴影配置,单元格只用通用编号。 + */ +export default { + mount(container) { + // 580px 容器、左右各一冻结列产生可见的中间横向滚动区。 + container.style.width = '580px'; + container.style.height = '420px'; + const columns = Array.from({ length: 10 }, (_, col) => ({ field: 'c' + col, title: 'Column ' + (col + 1), width: 140 })); + const records = Array.from({ length: 8 }, (_, row) => + Object.fromEntries(columns.map((column, col) => [column.field, 'R' + (row + 1) + ' / C' + (col + 1)]))); + return new window.VTable.ListTable(container, { + columns, records, frozenColCount: 1, rightFrozenColCount: 1, + defaultRowHeight: 42, defaultHeaderRowHeight: 44, widthMode: 'standard', + theme: window.VTable.themes.DEFAULT.extends({ + frozenColumnLine: { shadow: { width: 18, startColor: 'rgba(34, 78, 138, 0.58)', + endColor: 'rgba(34, 78, 138, 0)', visible: 'overflow' } } + }) + }); + }, + async exercise(page) { + // 与来源相同地用 setScrollLeft 定位;起点用例额外重放横向滚动后回到起点。 + await page.evaluate(() => window.__visualTable.setScrollLeft(Number.MAX_SAFE_INTEGER)); + + await page.waitForFunction(expected => { + const table = window.__visualTable; + const left = table.scenegraph.component.frozenShadowLine.attribute.visible; + const right = table.scenegraph.component.rightFrozenShadowLine.attribute.visible; + return left === expected.left && right === expected.right; + }, { left: true, right: false }); + }, + async verify(page) { + // 检查滚动位置、冻结列数和阴影实际可见状态,截图负责颜色及边界。 + await page.evaluate(expected => { + const table = window.__visualTable; + const left = table.scenegraph.component.frozenShadowLine.attribute.visible; + const right = table.scenegraph.component.rightFrozenShadowLine.attribute.visible; + if (table.frozenColCount !== 1 || table.rightFrozenColCount !== 1 || + left !== expected.left || right !== expected.right || + (expected.position === 'start' ? table.scrollLeft !== 0 : table.scrollLeft <= 0)) + throw new Error('冻结阴影溢出状态错误'); + }, { left: true, right: false, position: 'end' }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-middle.mjs b/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-middle.mjs new file mode 100644 index 0000000000..6add3e3b4a --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-middle.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 6aaba8136d3a95005dc7fa6e + * 验证目的:横向滚动到 middle 时,冻结列阴影仅在存在溢出的一侧显示。 + * 改写:保留来源的 10 列宽度与 overflow 阴影配置,单元格只用通用编号。 + */ +export default { + mount(container) { + // 580px 容器、左右各一冻结列产生可见的中间横向滚动区。 + container.style.width = '580px'; + container.style.height = '420px'; + const columns = Array.from({ length: 10 }, (_, col) => ({ field: 'c' + col, title: 'Column ' + (col + 1), width: 140 })); + const records = Array.from({ length: 8 }, (_, row) => + Object.fromEntries(columns.map((column, col) => [column.field, 'R' + (row + 1) + ' / C' + (col + 1)]))); + return new window.VTable.ListTable(container, { + columns, records, frozenColCount: 1, rightFrozenColCount: 1, + defaultRowHeight: 42, defaultHeaderRowHeight: 44, widthMode: 'standard', + theme: window.VTable.themes.DEFAULT.extends({ + frozenColumnLine: { shadow: { width: 18, startColor: 'rgba(34, 78, 138, 0.58)', + endColor: 'rgba(34, 78, 138, 0)', visible: 'overflow' } } + }) + }); + }, + async exercise(page) { + // 与来源相同地用 setScrollLeft 定位;起点用例额外重放横向滚动后回到起点。 + await page.evaluate(() => window.__visualTable.setScrollLeft(280)); + + await page.waitForFunction(expected => { + const table = window.__visualTable; + const left = table.scenegraph.component.frozenShadowLine.attribute.visible; + const right = table.scenegraph.component.rightFrozenShadowLine.attribute.visible; + return left === expected.left && right === expected.right; + }, { left: true, right: true }); + }, + async verify(page) { + // 检查滚动位置、冻结列数和阴影实际可见状态,截图负责颜色及边界。 + await page.evaluate(expected => { + const table = window.__visualTable; + const left = table.scenegraph.component.frozenShadowLine.attribute.visible; + const right = table.scenegraph.component.rightFrozenShadowLine.attribute.visible; + if (table.frozenColCount !== 1 || table.rightFrozenColCount !== 1 || + left !== expected.left || right !== expected.right || + (expected.position === 'start' ? table.scrollLeft !== 0 : table.scrollLeft <= 0)) + throw new Error('冻结阴影溢出状态错误'); + }, { left: true, right: true, position: 'middle' }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-start.mjs b/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-start.mjs new file mode 100644 index 0000000000..976e3a89db --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/frozen-shadow-start.mjs @@ -0,0 +1,50 @@ +/** + * BugServer case IDs: 6aaba81349fc4f005c341b4c + * 验证目的:横向滚动到 start 时,冻结列阴影仅在存在溢出的一侧显示。 + * 改写:保留来源的 10 列宽度与 overflow 阴影配置,单元格只用通用编号。 + */ +export default { + mount(container) { + // 580px 容器、左右各一冻结列产生可见的中间横向滚动区。 + container.style.width = '580px'; + container.style.height = '420px'; + const columns = Array.from({ length: 10 }, (_, col) => ({ field: 'c' + col, title: 'Column ' + (col + 1), width: 140 })); + const records = Array.from({ length: 8 }, (_, row) => + Object.fromEntries(columns.map((column, col) => [column.field, 'R' + (row + 1) + ' / C' + (col + 1)]))); + return new window.VTable.ListTable(container, { + columns, records, frozenColCount: 1, rightFrozenColCount: 1, + defaultRowHeight: 42, defaultHeaderRowHeight: 44, widthMode: 'standard', + theme: window.VTable.themes.DEFAULT.extends({ + frozenColumnLine: { shadow: { width: 18, startColor: 'rgba(34, 78, 138, 0.58)', + endColor: 'rgba(34, 78, 138, 0)', visible: 'overflow' } } + }) + }); + }, + async exercise(page) { + // 与来源相同地用 setScrollLeft 定位;起点用例额外重放横向滚动后回到起点。 + await page.evaluate(() => window.__visualTable.setScrollLeft(0)); + const host = await page.locator('#table').boundingBox(); + await page.mouse.move(host.x + 280, host.y + 100); + await page.mouse.wheel(280, 0); + await page.waitForFunction(() => window.__visualTable.scrollLeft > 0); + await page.evaluate(() => window.__visualTable.setScrollLeft(0)); + await page.waitForFunction(expected => { + const table = window.__visualTable; + const left = table.scenegraph.component.frozenShadowLine.attribute.visible; + const right = table.scenegraph.component.rightFrozenShadowLine.attribute.visible; + return left === expected.left && right === expected.right; + }, { left: false, right: true }); + }, + async verify(page) { + // 检查滚动位置、冻结列数和阴影实际可见状态,截图负责颜色及边界。 + await page.evaluate(expected => { + const table = window.__visualTable; + const left = table.scenegraph.component.frozenShadowLine.attribute.visible; + const right = table.scenegraph.component.rightFrozenShadowLine.attribute.visible; + if (table.frozenColCount !== 1 || table.rightFrozenColCount !== 1 || + left !== expected.left || right !== expected.right || + (expected.position === 'start' ? table.scrollLeft !== 0 : table.scrollLeft <= 0)) + throw new Error('冻结阴影溢出状态错误'); + }, { left: false, right: true, position: 'start' }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/one-column-left.mjs b/packages/vtable/__tests__/visual/cases/frozen/one-column-left.mjs new file mode 100644 index 0000000000..901ca11ba2 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/one-column-left.mjs @@ -0,0 +1,22 @@ +/** + * BugServer case IDs: 65adebb6af6266008c860c51 + * 验证目的:唯一一列配置为左冻结列时,表格仍能绘制记录。 + * 改写:保留单列、showPin 与 frozenColCount=1;名称改为通用值。 + */ +export default { + mount(container) { + // 单列表格不能借其他非冻结列掩盖边界问题。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }], + records: [{ id: 1 }], showPin: true, widthMode: 'standard', frozenColCount: 1 + }); + }, + async verify(page) { + // 验证唯一数据格仍可访问且原始左冻结配置仍被保留。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount !== 1 || table.getCellValue(0, 1) !== 1 || table.options.frozenColCount !== 1) + throw new Error('单列左冻结配置或绘制失效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/one-column-right.mjs b/packages/vtable/__tests__/visual/cases/frozen/one-column-right.mjs new file mode 100644 index 0000000000..9983acd520 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/one-column-right.mjs @@ -0,0 +1,22 @@ +/** + * BugServer case IDs: 65adebf8366fb000953fee50 + * 验证目的:唯一一列同时是右冻结列时,表格仍能绘制记录。 + * 改写:保留单列、showPin 与 rightFrozenColCount=1;名称改为通用值。 + */ +export default { + mount(container) { + // 右冻结覆盖全表宽度,直接触及左右边界重合场景。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }], + records: [{ id: 1 }], showPin: true, widthMode: 'standard', rightFrozenColCount: 1 + }); + }, + async verify(page) { + // 验证右冻结数和唯一单元格都存在。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount !== 1 || table.getCellValue(0, 1) !== 1 || table.rightFrozenColCount !== 1) + throw new Error('单列右冻结失效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/pivot-max-frozen-width.mjs b/packages/vtable/__tests__/visual/cases/frozen/pivot-max-frozen-width.mjs new file mode 100644 index 0000000000..c23a7c763e --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/pivot-max-frozen-width.mjs @@ -0,0 +1,31 @@ +/** + * BugServer case IDs: 676e6ce88e768100b114c2f0 + * 验证目的:透视表冻结行头超过 maxFrozenWidth 时逐列解冻而非全部解冻。 + * 改写:用三层匿名行维度及固定金额代替来源的大型数据表。 + */ +export default { + mount(container) { + // 三层行头的总宽超过容器一半,触发冻结宽度裁剪。 + container.style.width = '700px'; + container.style.height = '320px'; + return new window.VTable.PivotTable(container, { + rows: ['group', 'type', 'item'], columns: ['period'], indicators: ['amount'], + records: [ + { group: 'A', type: 'X', item: 'X1', period: 'First', amount: 10 }, + { group: 'A', type: 'X', item: 'X2', period: 'Second', amount: 20 }, + { group: 'B', type: 'Y', item: 'Y1', period: 'First', amount: 30 } + ], + defaultHeaderColWidth: 170, defaultColWidth: 150, + frozenColCount: 3, maxFrozenWidth: '40%', unfreezeAllOnExceedsMaxWidth: false, + widthMode: 'standard' + }); + }, + async verify(page) { + // 冻结列必须少于请求的三列且仍保留至少一列。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.frozenColCount < 1 || table.frozenColCount >= 3) + throw new Error(`冻结宽度未逐列裁剪:${table.frozenColCount}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/right-bottom.mjs b/packages/vtable/__tests__/visual/cases/frozen/right-bottom.mjs new file mode 100644 index 0000000000..77cdbd261d --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/right-bottom.mjs @@ -0,0 +1,23 @@ +/** + * BugServer case IDs: 65e1837e7c3a0c00c99f2c4a + * 验证目的:左右冻结列与底部冻结行同时配置时可绘制。 + * 改写:原例的订单字段换成普通编号;保留左右冻结计数重叠的边界条件。 + */ +export default { + mount(container) { + // 两列共用字段,左冻一列、右冻两列覆盖来源边界组合。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 'auto', sort: true }, + { field: 'id', title: 'ID copy', width: 'auto', sort: true }], + records: [{ id: 'A-001' }], frozenColCount: 1, rightFrozenColCount: 2, + bottomFrozenRowCount: 1, widthMode: 'standard' + }); + }, + async verify(page) { + // 冻结交叠不应损坏唯一记录或表格尺寸。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 1) !== 'A-001' || table.colCount !== 2) throw new Error('冻结列绘制异常'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/scroll-frozen-columns.mjs b/packages/vtable/__tests__/visual/cases/frozen/scroll-frozen-columns.mjs new file mode 100644 index 0000000000..1dcb890270 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/scroll-frozen-columns.mjs @@ -0,0 +1,42 @@ +/** + * BugServer case IDs: 69c23daa709fdc006857f1c8 + * 验证目的:左右冻结区在内容宽于视口时各自接收横向滚轮。 + * 改写:来源的重复人员列改为八个匿名数值列,保留两侧冻结宽度限制。 + */ +export default { + mount(container) { + // 左右冻结内容均超过各自 180px 视口,产生独立内部滚动范围。 + container.style.width = '700px'; + container.style.height = '310px'; + return new window.VTable.ListTable(container, { + columns: Array.from({ length: 8 }, (_, index) => ({ + field: `c${index}`, title: `C${index}`, width: 120 + })), + records: Array.from({ length: 6 }, (_, row) => + Object.fromEntries(Array.from({ length: 8 }, (_, col) => [`c${col}`, `${row}-${col}`]))), + frozenColCount: 3, rightFrozenColCount: 2, + maxFrozenWidth: 180, maxRightFrozenWidth: 180, + scrollFrozenCols: true, scrollRightFrozenCols: true, + widthMode: 'standard' + }); + }, + async exercise(page) { + // 在左冻结区向右滚,在右冻结区反向滚以展开两边被裁剪的内容。 + const host = await page.locator('#table').boundingBox(); + await page.mouse.move(host.x + 80, host.y + 90); + await page.mouse.wheel(90, 0); + await page.mouse.move(host.x + host.width - 80, host.y + 90); + await page.mouse.wheel(-90, 0); + }, + async verify(page) { + // 两侧各自的滚动位置均改变,冻结列数没有被宽度上限自动削减。 + await page.evaluate(() => { + const table = window.__visualTable; + const scroll = table.stateManager.scroll; + if (table.frozenColCount !== 3 || table.rightFrozenColCount !== 2 || + table.getFrozenColsOffset() <= 0 || table.getRightFrozenColsOffset() <= 0 || + scroll.frozenHorizontalBarPos <= 0 || scroll.rightFrozenHorizontalBarPos <= 0) + throw new Error(`冻结区内部滚动未生效:${JSON.stringify(scroll)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/scrollbar-ignore-frozen.mjs b/packages/vtable/__tests__/visual/cases/frozen/scrollbar-ignore-frozen.mjs new file mode 100644 index 0000000000..2984804434 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/scrollbar-ignore-frozen.mjs @@ -0,0 +1,42 @@ +/** + * BugServer case IDs: 68a2e109fcb96500a7134dd7 + * 验证目的:运行时设置冻结列和横向滚动后,ignoreFrozenCols 使主滚动条跨完整表宽。 + * 改写:重复人员列替换成编号列,保留来源的尺寸、主题和 API 调用。 + */ +export default { + mount(container) { + // 固定七列使主体溢出,后续再按来源动态设置冻结列数。 + container.style.width = '800px'; + container.style.height = '400px'; + const columns = Array.from({ length: 7 }, (_, col) => ({ field: 'c' + col, title: 'Column ' + col, width: col === 0 ? 150 : 200 })); + const table = new window.VTable.ListTable(container, { + columns, widthMode: 'standard', frozenColCount: 0, + theme: { + frozenColumnLine: { shadow: { width: 24, startColor: 'rgba(0, 24, 47, 0.06)', endColor: 'rgba(0, 24, 47, 0)' } }, + scrollStyle: { visible: 'always', ignoreFrozenCols: true } + } + }); + table.setRecords(Array.from({ length: 5 }, (_, row) => + Object.fromEntries(columns.map((column, col) => [column.field, row * 10 + col])))); + return table; + }, + async exercise(page) { + // 与来源相同地动态设置冻结列数,再滚动到横向中间位置。 + await page.evaluate(() => { + const table = window.__visualTable; + table.frozenColCount = 2; + table.setScrollLeft(200); + }); + await page.waitForFunction(() => window.__visualTable.scrollLeft === 200); + }, + async verify(page) { + // 滚动条总宽必须包含冻结区,主体内容也必须已滚动。 + await page.evaluate(() => { + const table = window.__visualTable; + const bar = table.scenegraph.component.hScrollBar.attribute; + if (table.frozenColCount !== 2 || table.scrollLeft !== 200 || + !table.theme.scrollStyle.ignoreFrozenCols || bar.width < table.tableNoFrameWidth - 5) + throw new Error('冻结列仍被主滚动条排除'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/short-content-connected.mjs b/packages/vtable/__tests__/visual/cases/frozen/short-content-connected.mjs new file mode 100644 index 0000000000..ec8f51a061 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/short-content-connected.mjs @@ -0,0 +1,30 @@ +/** + * BugServer case IDs: 6a83c88f652957005d019ab2 + * 验证目的:短内容大容器中,左冻结列、顶部五行和底部两行冻结区相接。 + * 改写:使用通用本地记录,不保留示例邮箱,保留冻结数量与底部颜色。 + */ +export default { + mount(container) { + // 大于内容高度的容器暴露上下冻结区连接边界。 + container.style.width = '1000px'; + container.style.height = '800px'; + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 120 }, + { field: 'name', title: 'Name', width: 180 }, { field: 'city', title: 'City', width: 180 }, + { field: 'sales', title: 'Sales', width: 180 }, { field: 'note', title: 'Note', width: 200 }], + records: Array.from({ length: 10 }, (_, i) => ({ id: i + 1, name: `Name ${i + 1}`, + city: ['A', 'B', 'C'][i % 3], sales: 1000 + i * 100, note: `Row ${i + 1}` })), + frozenColCount: 2, rightFrozenColCount: 0, frozenRowCount: 5, bottomFrozenRowCount: 2, + defaultColWidth: 150, theme: { bottomFrozenStyle: { bgColor: '#fff7cc' } } + }); + }, + async verify(page) { + // 冻结分区计数和最后一条记录必须符合源配置。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.frozenColCount !== 2 || table.frozenRowCount !== 5 || + table.bottomFrozenRowCount !== 2 || table.getCellValue(0, 10) !== 10) + throw new Error('短内容冻结区未正确建立'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/frozen/top-bottom-border.mjs b/packages/vtable/__tests__/visual/cases/frozen/top-bottom-border.mjs new file mode 100644 index 0000000000..b37a2456ad --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/frozen/top-bottom-border.mjs @@ -0,0 +1,27 @@ +/** + * BugServer case IDs: 6a83c8713335650068dcf413 + * 验证目的:顶部冻结三行、底部冻结一行时,红色外边框仍连续绘制。 + * 改写:保留冻结数量和四边框配置,使用固定编号数据。 + */ +export default { + mount(container) { + // 表体与两端冻结区同时可见,边框问题由截图比较发现。 + container.style.height = '500px'; + return new window.VTable.ListTable(container, { + columns: ['id', 'name', 'city', 'sales', 'profit'].map(field => ({ field, title: field, width: 170 })), + records: Array.from({ length: 6 }, (_, i) => ({ + id: i + 1, name: `Name ${i + 1}`, city: `City ${i + 1}`, sales: 1000 + i * 100, profit: 120 + i * 10 + })), + frozenRowCount: 3, bottomFrozenRowCount: 1, widthMode: 'standard', + theme: { frameStyle: { borderLineWidth: [1, 1, 1, 1], borderColor: 'red' } } + }); + }, + async verify(page) { + // 最后冻结行仍指向最后一条记录。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.frozenRowCount !== 3 || table.bottomFrozenRowCount !== 1 || table.getCellValue(0, 6) !== 6) + throw new Error('上下冻结行边界错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/baseline-bottom.mjs b/packages/vtable/__tests__/visual/cases/gantt/baseline-bottom.mjs new file mode 100644 index 0000000000..f3762f0fd5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/baseline-bottom.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 69a6810b2d80dd005eccdeac + * 验证目的:Gantt 任务条下方显示计划基线,三级时间轴与进度同时绘制。 + * 改写:任务文字和人员字段改为通用名称,保留实际、基线日期及样式结构。 + */ +export default { + mount(container) { + // 固定跨月日期,确保月、周、日三级刻度与基线都进入截图。 + container.style.height = '400px'; + return new window.VTableGantt.Gantt(container, { + records: [ + { id: 1, title: 'Task A', startDate: '2024-07-05', endDate: '2024-07-14', + baselineStartDate: '2024-07-01', baselineEndDate: '2024-07-10', progress: 80 }, + { id: 2, title: 'Task B', startDate: '2024-07-15', endDate: '2024-07-25', + baselineStartDate: '2024-07-11', baselineEndDate: '2024-07-20', progress: 40 }, + { id: 3, title: 'Task C', startDate: '2024-08-05', endDate: '2024-08-20', + baselineStartDate: '2024-08-01', baselineEndDate: '2024-08-15', progress: 0 } + ], + taskListTable: { columns: [ + { field: 'title', title: 'Task', width: 100 }, + { field: 'progress', title: 'Progress', width: 100, format: value => `${value}%` } + ], tableWidth: 'auto', minTableWidth: 100, maxTableWidth: 500 }, + headerRowHeight: 50, rowHeight: 90, + taskBar: { + startDateField: 'startDate', endDateField: 'endDate', progressField: 'progress', + baselineStartDateField: 'baselineStartDate', baselineEndDateField: 'baselineEndDate', + labelText: '{title}', labelTextStyle: { fontFamily: 'Arial', fontSize: 14, color: '#ffffff' }, + barStyle: { width: 25, barColor: '#3498db', completedBarColor: '#27ae60', cornerRadius: 5 }, + baselineStyle: { width: 15, barColor: 'gray', cornerRadius: 5 } + }, + timelineHeader: { backgroundColor: '#EEF1F5', colWidth: 50, scales: [ + { unit: 'month', step: 1 }, { unit: 'week', step: 1, startOfWeek: 'monday' }, { unit: 'day', step: 1 } + ] }, + minDate: '2024-06-30', maxDate: '2024-09-01', + grid: { horizontalLine: { lineWidth: 1, lineColor: '#e1e4e8' } }, overscrollBehavior: 'none' + }); + }, + async verify(page) { + // 任务表与任务条的矩形应同时有效,基线外观由截图比较。 + await page.evaluate(() => { + const gantt = window.__visualTable; + const rect = gantt.getTaskBarRelativeRect(0); + if (!rect || !(rect.width > 0) || !gantt.taskListTableInstance) + throw new Error('Gantt 任务条或任务表缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/baseline-overlap.mjs b/packages/vtable/__tests__/visual/cases/gantt/baseline-overlap.mjs new file mode 100644 index 0000000000..8eb4b7fd46 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/baseline-overlap.mjs @@ -0,0 +1,35 @@ +/** + * BugServer case IDs: 69a681c22d80dd005eccdead + * 验证目的:基线与任务条使用 overlap 布局时两层都进入绘制。 + * 改写:任务名称匿名化,保留交叠日期、进度与 overlap 配置。 + */ +export default { + mount(container) { + // 第一条基线先于任务开始,第二条基线与任务结束重叠。 + container.style.height = '400px'; + return new window.VTableGantt.Gantt(container, { + records: [{ id: 1, title: 'Task A', startDate: '2024-07-05', endDate: '2024-07-14', + baselineStartDate: '2024-07-01', baselineEndDate: '2024-07-10', progress: 80 }, + { id: 2, title: 'Task B', startDate: '2024-07-08', endDate: '2024-07-12', + baselineStartDate: '2024-07-03', baselineEndDate: '2024-07-08', progress: 100 }], + taskListTable: { columns: [{ field: 'title', title: 'Task', width: 100 }, + { field: 'progress', title: 'Progress', width: 100 }], tableWidth: 'auto', minTableWidth: 100, maxTableWidth: 500 }, + headerRowHeight: 50, rowHeight: 90, + taskBar: { startDateField: 'startDate', endDateField: 'endDate', progressField: 'progress', + baselineStartDateField: 'baselineStartDate', baselineEndDateField: 'baselineEndDate', + baselinePosition: 'overlap', labelText: '{title}', + barStyle: { width: 25, barColor: '#3498db', completedBarColor: '#27ae60' }, + baselineStyle: { width: 15, barColor: 'gray' } }, + timelineHeader: { colWidth: 50, scales: [{ unit: 'week', step: 1, startOfWeek: 'monday' }, + { unit: 'day', step: 1 }] }, minDate: '2024-06-30', maxDate: '2024-08-01' + }); + }, + async verify(page) { + // 两条任务条的相对矩形均须可用,基线相对位置由截图比较。 + await page.evaluate(() => { + const gantt = window.__visualTable; + if (gantt.records.length !== 2 || !(gantt.getTaskBarRelativeRect(0)?.width > 0) || + !(gantt.getTaskBarRelativeRect(1)?.width > 0)) throw new Error('overlap 任务条未绘制'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/baseline-padding-function.mjs b/packages/vtable/__tests__/visual/cases/gantt/baseline-padding-function.mjs new file mode 100644 index 0000000000..4bc35b8709 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/baseline-padding-function.mjs @@ -0,0 +1,42 @@ +/** + * BugServer case IDs: 69a68300ec91eb005f9ad659 + * 验证目的:barStyle 与 baselineStyle 函数根据任务索引给出不同 paddingTop。 + * 改写:匿名化任务,只保留来源的索引计算与日期关系。 + */ +export default { + mount(container) { + // 两条任务触发不同的函数样式分支,使错位变化进入截图。 + container.style.height = '400px'; + return new window.VTableGantt.Gantt(container, { + records: [ + { id: 1, title: 'Task A', startDate: '2024-07-05', endDate: '2024-07-14', + baselineStartDate: '2024-07-01', baselineEndDate: '2024-07-10', progress: 80 }, + { id: 2, title: 'Task B', startDate: '2024-07-08', endDate: '2024-07-12', + baselineStartDate: '2024-07-03', baselineEndDate: '2024-07-08', progress: 100 } + ], + taskListTable: { columns: [{ field: 'title', title: 'Task', width: 100 }, + { field: 'progress', title: 'Progress', width: 100 }], + tableWidth: 'auto', minTableWidth: 100, maxTableWidth: 500 }, + headerRowHeight: 50, rowHeight: 90, + taskBar: { startDateField: 'startDate', endDateField: 'endDate', progressField: 'progress', + baselineStartDateField: 'baselineStartDate', baselineEndDateField: 'baselineEndDate', + labelText: '{title}', + // 函数配置是此来源与固定样式用例的核心差异。 + barStyle: ({ index }) => ({ paddingTop: 40 + 20 * ((index + 1) % 2), + width: 25, barColor: '#3498db', completedBarColor: '#27ae60' }), + baselineStyle: ({ index }) => ({ paddingTop: 10 + 20 * (index % 2), + width: 15, barColor: 'gray' }) }, + timelineHeader: { colWidth: 50, scales: [{ unit: 'month', step: 1 }, + { unit: 'week', step: 1, startOfWeek: 'monday' }, { unit: 'day', step: 1 }] }, + minDate: '2024-06-30', maxDate: '2024-08-01' + }); + }, + async verify(page) { + // 两条不同索引任务必须可见,具体纵向布局通过视觉截图比较。 + await page.evaluate(() => { + const gantt = window.__visualTable; + if (gantt.records.length !== 2 || !(gantt.getTaskBarRelativeRect(0)?.width > 0) || + !(gantt.getTaskBarRelativeRect(1)?.width > 0)) throw new Error('函数 paddingTop 任务未绘制'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/baseline-padding-static.mjs b/packages/vtable/__tests__/visual/cases/gantt/baseline-padding-static.mjs new file mode 100644 index 0000000000..817bf3e1c7 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/baseline-padding-static.mjs @@ -0,0 +1,39 @@ +/** + * BugServer case IDs: 69a682bc1d0efd005e397e30 + * 验证目的:固定 paddingTop 分别移动任务条与计划基线,月周日刻度同时绘制。 + * 改写:任务和人员信息替换为通用文字,保留行高、日期与基线样式。 + */ +export default { + mount(container) { + // 两条任务覆盖不同进度,验证静态间距在同一行高下稳定绘制。 + container.style.height = '400px'; + return new window.VTableGantt.Gantt(container, { + records: [ + { id: 1, title: 'Task A', startDate: '2024-07-05', endDate: '2024-07-14', + baselineStartDate: '2024-07-01', baselineEndDate: '2024-07-10', progress: 80 }, + { id: 2, title: 'Task B', startDate: '2024-07-08', endDate: '2024-07-12', + baselineStartDate: '2024-07-03', baselineEndDate: '2024-07-08', progress: 100 } + ], + taskListTable: { columns: [{ field: 'title', title: 'Task', width: 100 }, + { field: 'progress', title: 'Progress', width: 100 }], + tableWidth: 'auto', minTableWidth: 100, maxTableWidth: 500 }, + headerRowHeight: 50, rowHeight: 90, + taskBar: { startDateField: 'startDate', endDateField: 'endDate', progressField: 'progress', + baselineStartDateField: 'baselineStartDate', baselineEndDateField: 'baselineEndDate', + labelText: '{title}', + barStyle: { paddingTop: 40, width: 25, barColor: '#3498db', completedBarColor: '#27ae60' }, + baselineStyle: { paddingTop: 10, width: 15, barColor: 'gray' } }, + timelineHeader: { colWidth: 50, scales: [{ unit: 'month', step: 1 }, + { unit: 'week', step: 1, startOfWeek: 'monday' }, { unit: 'day', step: 1 }] }, + minDate: '2024-06-30', maxDate: '2024-08-01' + }); + }, + async verify(page) { + // 两条任务必须各自形成有效的任务条矩形,间距外观由基线截图检查。 + await page.evaluate(() => { + const gantt = window.__visualTable; + if (gantt.records.length !== 2 || !(gantt.getTaskBarRelativeRect(0)?.width > 0) || + !(gantt.getTaskBarRelativeRect(1)?.width > 0)) throw new Error('静态 paddingTop 任务未绘制'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/baseline-top.mjs b/packages/vtable/__tests__/visual/cases/gantt/baseline-top.mjs new file mode 100644 index 0000000000..0ddd110f50 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/baseline-top.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 69a681915bd8c7005e123d92 + * 验证目的:Gantt 任务条上方显示计划基线,三级时间轴与进度同时绘制。 + * 改写:任务文字和人员字段改为通用名称,保留实际、基线日期及样式结构。 + */ +export default { + mount(container) { + // 固定跨月日期,确保月、周、日三级刻度与基线都进入截图。 + container.style.height = '400px'; + return new window.VTableGantt.Gantt(container, { + records: [ + { id: 1, title: 'Task A', startDate: '2024-07-05', endDate: '2024-07-14', + baselineStartDate: '2024-07-01', baselineEndDate: '2024-07-10', progress: 80 }, + { id: 2, title: 'Task B', startDate: '2024-07-15', endDate: '2024-07-25', + baselineStartDate: '2024-07-11', baselineEndDate: '2024-07-20', progress: 40 }, + { id: 3, title: 'Task C', startDate: '2024-08-05', endDate: '2024-08-20', + baselineStartDate: '2024-08-01', baselineEndDate: '2024-08-15', progress: 0 } + ], + taskListTable: { columns: [ + { field: 'title', title: 'Task', width: 100 }, + { field: 'progress', title: 'Progress', width: 100, format: value => `${value}%` } + ], tableWidth: 'auto', minTableWidth: 100, maxTableWidth: 500 }, + headerRowHeight: 50, rowHeight: 90, + taskBar: { + startDateField: 'startDate', endDateField: 'endDate', progressField: 'progress', + baselineStartDateField: 'baselineStartDate', baselineEndDateField: 'baselineEndDate', + labelText: '{title}', labelTextStyle: { fontFamily: 'Arial', fontSize: 14, color: '#ffffff' }, + barStyle: { width: 25, barColor: '#3498db', completedBarColor: '#27ae60', cornerRadius: 5 }, + baselinePosition: 'top', baselineStyle: { width: 15, barColor: 'gray', cornerRadius: 5 } + }, + timelineHeader: { backgroundColor: '#EEF1F5', colWidth: 50, scales: [ + { unit: 'month', step: 1 }, { unit: 'week', step: 1, startOfWeek: 'monday' }, { unit: 'day', step: 1 } + ] }, + minDate: '2024-06-30', maxDate: '2024-09-01', + grid: { horizontalLine: { lineWidth: 1, lineColor: '#e1e4e8' } }, overscrollBehavior: 'none' + }); + }, + async verify(page) { + // 任务表与任务条的矩形应同时有效,基线外观由截图比较。 + await page.evaluate(() => { + const gantt = window.__visualTable; + const rect = gantt.getTaskBarRelativeRect(0); + if (!rect || !(rect.width > 0) || !gantt.taskListTableInstance) + throw new Error('Gantt 任务条或任务表缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/empty-mark-line.mjs b/packages/vtable/__tests__/visual/cases/gantt/empty-mark-line.mjs new file mode 100644 index 0000000000..9e7e5cb3d5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/empty-mark-line.mjs @@ -0,0 +1,28 @@ +/** + * BugServer case IDs: 68948edf2b03ab00a30d5085 + * 验证目的:无任务记录的 Gantt 仍能绘制固定日期标记线和时间轴。 + * 改写:用固定日期代替日期辅助函数,保留无 records 与 markLine 对象。 + */ +export default { + mount(container) { + // 空任务列表只保留时间轴和标记线,防止记录掩盖空状态错误。 + container.style.height = '400px'; + return new window.VTableGantt.Gantt(container, { + taskListTable: { columns: [{ field: 'title', title: 'Task', width: 200 }, + { field: 'start', title: 'Start', width: 150 }, { field: 'end', title: 'End', width: 150 }], + tableWidth: 400, minTableWidth: 100, maxTableWidth: 600 }, + taskBar: { startDateField: 'start', endDateField: 'end', labelText: '{title}' }, + timelineHeader: { colWidth: 60, scales: [{ unit: 'week', step: 1, startOfWeek: 'sunday' }, + { unit: 'day', step: 1 }] }, markLine: { date: new Date('2024-08-26T00:00:00') }, + minDate: '2024-08-01', maxDate: '2024-09-30', overscrollBehavior: 'none' + }); + }, + async verify(page) { + // 无记录时仍须有任务列表实例和有效时间轴宽度。 + await page.evaluate(() => { + const gantt = window.__visualTable; + if (gantt.records?.length || !gantt.taskListTableInstance || !(gantt.getXByTime(new Date('2024-08-26').getTime()) > 0)) + throw new Error('空 Gantt 标记线场景无效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/locate-offscreen-task.mjs b/packages/vtable/__tests__/visual/cases/gantt/locate-offscreen-task.mjs new file mode 100644 index 0000000000..9f51b211a0 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/locate-offscreen-task.mjs @@ -0,0 +1,62 @@ +/** + * BugServer case IDs: 69c38c89a81058005d23c094 + * 验证目的:点击时间轴左右两侧的任务定位图标,可将视口滚动到对应任务条。 + * 改写:匿名化任务名称,保留跨年时间轴、左右离屏任务及两次真实点击。 + */ +export default { + mount(container) { + // 视口先停在年中,使前后任务分别位于左右离屏区。 + container.style.height = '400px'; + const gantt = new window.VTableGantt.Gantt(container, { + records: [ + { id: 1, title: 'Task A', start: '2024-02-05', end: '2024-02-20', progress: 20 }, + { id: 2, title: 'Task B', start: '2024-03-10', end: '2024-03-18', progress: 60 }, + { id: 3, title: 'Task C', start: '2024-05-28', end: '2024-06-05', progress: 50 }, + { id: 4, title: 'Task D', start: '2024-10-05', end: '2024-10-20', progress: 40 }, + { id: 5, title: 'Task E', start: '2024-11-10', end: '2024-11-25', progress: 80 } + ], + taskListTable: { columns: [{ field: 'title', title: 'Task', width: 160 }, + { field: 'start', title: 'Start', width: 120 }], + tableWidth: 280, minTableWidth: 240, maxTableWidth: 600 }, + taskKeyField: 'id', taskBar: { startDateField: 'start', endDateField: 'end', + progressField: 'progress', locateIcon: true }, + minDate: '2024-01-01', maxDate: '2024-12-31', + timelineHeader: { colWidth: 30, scales: [{ unit: 'day', step: 1 }] }, + scrollStyle: { visible: 'scrolling' } + }); + gantt.scrollLeft = gantt.getXByTime(new Date('2024-06-01T00:00:00').getTime()); + window.__locateInitialScroll = gantt.scrollLeft; + return gantt; + }, + async exercise(page) { + // 按任务定位图标的真实场景边界点击,先向左再向右定位。 + const clickIcon = async side => { + // 每次滚动后重新寻找图标,避免使用失效的场景坐标。 + const point = await page.evaluate(side => { + const gantt = window.__visualTable; + const group = gantt.scenegraph.taskBar.locateIconsGroup; + const icon = [...(group?.children ?? [])].find(mark => + mark.name === `task-bar-locate-icon-${side}` && mark.attribute.visibleAll !== false); + if (!icon) throw new Error(`${side} 定位图标缺失`); + const bounds = icon.globalAABBBounds; + const canvas = gantt.canvas.getBoundingClientRect(); + return { x: canvas.x + (bounds.x1 + bounds.x2) / 2, + y: canvas.y + (bounds.y1 + bounds.y2) / 2 }; + }, side); + await page.mouse.click(point.x, point.y); + }; + await clickIcon('left'); + await page.waitForFunction(() => window.__visualTable.scrollLeft < window.__locateInitialScroll - 100); + await page.evaluate(() => { window.__locateLeftScroll = window.__visualTable.scrollLeft; }); + await clickIcon('right'); + await page.waitForFunction(() => window.__visualTable.scrollLeft > window.__locateLeftScroll + 100); + }, + async verify(page) { + // 两侧点击后视口必须发生双向移动,任务条数据仍保留。 + await page.evaluate(() => { + const gantt = window.__visualTable; + if (gantt.records.length !== 5 || gantt.scrollLeft <= window.__locateLeftScroll) + throw new Error('离屏任务定位图标未完成双向滚动'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/move-task-bar.mjs b/packages/vtable/__tests__/visual/cases/gantt/move-task-bar.mjs new file mode 100644 index 0000000000..1236e27809 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/move-task-bar.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 6a326895a44900005e2b8e22 + * 验证目的:拖动任务条后起止日期与 MOVE_END_TASK_BAR 事件同步更新。 + * 改写:任务名称匿名化,保留按天时间轴、moveable 配置及真实拖动动作。 + */ +export default { + mount(container) { + // 两个普通任务为拖动前后提供稳定的时间和位置参照。 + container.style.height = '400px'; + const gantt = new window.VTableGantt.Gantt(container, { + records: [{ id: 1, title: 'Task A', start: '2024-12-05', end: '2024-12-12', progress: 20 }, + { id: 2, title: 'Task B', start: '2024-12-10', end: '2024-12-18', progress: 35 }], + taskListTable: { columns: [{ field: 'title', title: 'Task', width: 160 }, + { field: 'start', title: 'Start', width: 120 }], tableWidth: 280 }, + taskKeyField: 'id', taskBar: { startDateField: 'start', endDateField: 'end', + progressField: 'progress', moveable: true, labelText: '{title}' }, + minDate: '2024-12-01', maxDate: '2024-12-31', + timelineHeader: { colWidth: 30, scales: [{ unit: 'day', step: 1 }] } + }); + window.__moveTaskBarEvent = null; + gantt.on('move_end_task_bar', args => { window.__moveTaskBarEvent = args; }); + return gantt; + }, + async exercise(page) { + // 从首个任务条中心向右拖动两个日期刻度。 + const point = await page.evaluate(() => { + const rect = window.__visualTable.getTaskBarRelativeRect(0); + if (!rect) throw new Error('任务条未渲染'); + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + rect.left + rect.width / 2, + y: host.y + rect.top + rect.height / 2 }; + }); + await page.mouse.move(point.x, point.y); + await page.mouse.down(); + await page.mouse.move(point.x + 60, point.y, { steps: 12 }); + await page.mouse.up(); + }, + async verify(page) { + // 日期必须实质变化,且拖动完成事件引用同一条任务记录。 + await page.evaluate(() => { + const gantt = window.__visualTable; + if (gantt.records[0].start === '2024-12-05' || + window.__moveTaskBarEvent?.record?.id !== 1) + throw new Error(`任务拖动未更新:${gantt.records[0].start}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/project-range-set-records.mjs b/packages/vtable/__tests__/visual/cases/gantt/project-range-set-records.mjs new file mode 100644 index 0000000000..38cff1b924 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/project-range-set-records.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 6aaf8946952abe004538cf80 + * 验证目的:setRecords 引入项目子任务后,项目起止时间按子任务边界重算。 + * 改写:去掉截图状态标签,保留原始子任务的逆序时间边界。 + */ +export default { + mount(container) { + // 先创建普通任务,再用 setRecords 切换为无显式时间的项目。 + container.style.height = '360px'; + const gantt = new window.VTableGantt.Gantt(container, { + records: [{ id: 1, name: 'Initial task', start: '2024-11-01', end: '2024-11-02' }], + taskListTable: { columns: [{ field: 'name', title: 'Task', width: 160, tree: true }, + { field: 'start', title: 'Start', width: 110 }, { field: 'end', title: 'End', width: 110 }], tableWidth: 380 }, + tasksShowMode: window.VTableGantt.TYPES.TasksShowMode.Tasks_Separate, + taskBar: { startDateField: 'start', endDateField: 'end', labelText: '{name}' }, + timelineHeader: { colWidth: 50, scales: [{ unit: 'week', step: 1, startOfWeek: 'monday' }, + { unit: 'day', step: 1 }] }, minDate: '2024-11-01', maxDate: '2024-11-30' + }); + gantt.setRecords([{ id: 2, name: 'Project', type: 'project', children: [ + { id: 3, name: 'Design', start: '2024-11-15', end: '2024-11-18' }, + { id: 4, name: 'Review', start: '2024-11-12', end: '2024-11-20' } + ] }]); + return gantt; + }, + async verify(page) { + // 项目开始日期必须取较早子任务,结束日期必须取较晚子任务。 + await page.evaluate(() => { + const project = window.__visualTable.records[0]; + if (project.start !== '2024-11-12' || project.end !== '2024-11-20') + throw new Error(`项目时间未重算:${project.start} - ${project.end}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/resize-task-list-header.mjs b/packages/vtable/__tests__/visual/cases/gantt/resize-task-list-header.mjs new file mode 100644 index 0000000000..5455dd8f37 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/resize-task-list-header.mjs @@ -0,0 +1,56 @@ +/** + * 验证目的:缩窄 Gantt 任务列表列头后,双层日期时间轴和任务条仍正常布局。 + * 独立匿名用例;不关联含业务流程信息的 BugServer 来源 ID。 + */ +export default { + mount(container) { + // 固定的匿名任务和日、小时双层刻度形成列头与日期表头的交界。 + container.style.height = '400px'; + return new window.VTableGantt.Gantt(container, { + records: [ + { id: 'a', title: 'Task A', start: '2025-01-02 09:00', end: '2025-01-02 15:00', checked: false }, + { id: 'b', title: 'Task B', start: '2025-01-03 08:00', end: '2025-01-03 12:00', checked: false } + ], + taskListTable: { + columns: [ + { field: 'checked', title: '', width: 50, cellType: 'checkbox', headerType: 'checkbox' }, + { field: 'title', title: 'Task', width: 220 }, + { field: 'start', title: 'Start', width: 160 } + ], + tableWidth: 'auto', frozenColCount: 1 + }, + frame: { verticalSplitLineMoveable: true }, + taskBar: { startDateField: 'start', endDateField: 'end', labelText: '{title}' }, + minDate: '2025-01-02 00:00', maxDate: '2025-01-04 00:00', + markLine: [{ date: '2025-01-02 13:30:00', style: { lineColor: 'red', lineWidth: 1 } }], + rowHeight: 40, headerRowHeight: 40, + timelineHeader: { colWidth: 40, scales: [{ unit: 'day', step: 1 }, { unit: 'hour', step: 1 }] } + }); + }, + async exercise(page) { + // 按真实列头右边界向左拖动约 60px,而非直接调用调整列宽 API。 + const point = await page.evaluate(() => { + const table = window.__visualTable.taskListTableInstance; + const bounds = table.getCellRect(1, 0).bounds; + const host = table.getContainer().getBoundingClientRect(); + window.__ganttColumnWidthBefore = table.getColWidth(1); + return { x: host.x + bounds.x2 - 1, y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }); + await page.mouse.move(point.x, point.y); + await page.mouse.down(); + await page.mouse.move(point.x - 60, point.y, { steps: 12 }); + await page.mouse.up(); + }, + async verify(page) { + // 列宽应确实缩小,时间轴与任务条仍可计算有效位置。 + await page.evaluate(() => { + const gantt = window.__visualTable; + const table = gantt.taskListTableInstance; + const width = table.getColWidth(1); + const timeX = gantt.getXByTime(new Date('2025-01-02T13:30:00').getTime()); + if (width > window.__ganttColumnWidthBefore - 30 || + !Number.isFinite(timeX) || !gantt.getTaskBarRelativeRect(0)) + throw new Error(`Gantt 列头缩窄后布局错误:${window.__ganttColumnWidthBefore} -> ${width}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/taskbar-relative-rect.mjs b/packages/vtable/__tests__/visual/cases/gantt/taskbar-relative-rect.mjs new file mode 100644 index 0000000000..5f102d1520 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/taskbar-relative-rect.mjs @@ -0,0 +1,60 @@ +/** + * BugServer case IDs: 6a68596802c575005f77ab28 + * 验证目的:项目子任务内联显示时,空任务和两个子任务的相对矩形不同。 + * 改写:省略 BugServer 状态条,改由测试断言检查原有 click_cell 触发条件。 + */ +export default { + mount(container) { + // 固定时间轴和记录,保留来源用例的空行与两个分离子任务。 + container.style.height = '400px'; + const types = window.VTableGantt.TYPES; + const table = new window.VTableGantt.Gantt(container, { + records: [ + { id: 1, title: 'Project', type: types.TaskType.PROJECT, children: [ + { id: 11, title: 'Task A', start: '2024-07-01', end: '2024-07-04', progress: 30 }, + { id: 12, title: 'Task B', start: '2024-07-08', end: '2024-07-12', progress: 60 } + ] }, + { id: 2, title: 'Empty task row' } + ], + taskListTable: { columns: [ + { field: 'title', title: 'title', width: 220 }, + { field: 'start', title: 'start', width: 120 }, + { field: 'end', title: 'end', width: 120 }, + { field: 'progress', title: 'progress', width: 100 } + ], tableWidth: 260 }, + taskKeyField: 'id', + tasksShowMode: types.TasksShowMode.Project_Sub_Tasks_Inline, + taskBar: { startDateField: 'start', endDateField: 'end', progressField: 'progress' }, + minDate: '2024-07-01', maxDate: '2024-07-20', + timelineHeader: { colWidth: 36, scales: [{ unit: 'day', step: 1 }] }, + grid: { verticalLine: { lineWidth: 1, lineColor: '#e1e4e8' }, horizontalLine: { lineWidth: 1, lineColor: '#e1e4e8' } } + }); + window.__ganttClicked = false; + table.taskListTableInstance?.on('click_cell', args => { + if (args.col === 0 && args.row === 0) window.__ganttClicked = true; + }); + return table; + }, + async exercise(page) { + // 点击来源指定的左侧任务单元格,再验证事件与矩形。 + const point = await page.evaluate(() => { + const bounds = window.__visualTable.taskListTableInstance.getCellRect(0, 0).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (bounds.x1 + bounds.x2) / 2, y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 空任务必须无矩形,两个子任务必须各有有限且分离的布局。 + await page.evaluate(() => { + const table = window.__visualTable; + if (!window.__ganttClicked) throw new Error('任务表点击事件未触发'); + const empty = table.getTaskBarRelativeRect(1); + const first = table.getTaskBarRelativeRect(0, [0, 0]); + const second = table.getTaskBarRelativeRect(0, [0, 1]); + const valid = rect => rect && ['left', 'top', 'width', 'height'].every(key => Number.isFinite(rect[key])); + if (empty !== null || !valid(first) || !valid(second) || first.left === second.left) + throw new Error('任务条相对矩形不符合预期'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/gantt/tree-move-resize.mjs b/packages/vtable/__tests__/visual/cases/gantt/tree-move-resize.mjs new file mode 100644 index 0000000000..7ecc0618f9 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/gantt/tree-move-resize.mjs @@ -0,0 +1,60 @@ +/** + * BugServer case IDs: 67c161acd824e100b091a557 + * 验证目的:树形任务的子任务条可移动并调整结束日期。 + * 改写:任务名及日期换成固定示例,保留展开层级、moveable 和 resizable。 + */ +export default { + mount(container) { + // 展开的父任务使子任务条对应可见行 1。 + container.style.height = '400px'; + return new window.VTableGantt.Gantt(container, { + records: [{ id: 'group', title: 'Group', start: '2025-01-02', end: '2025-01-13', + progress: 20, hierarchyState: 'expand', children: [ + { id: 'child', title: 'Child', start: '2025-01-04', end: '2025-01-06', progress: 10 } + ] }], + taskListTable: { columns: [{ field: 'title', title: 'Task', width: 170, tree: true }, + { field: 'start', title: 'Start', width: 110 }], tableWidth: 280 }, + taskKeyField: 'id', taskBar: { startDateField: 'start', endDateField: 'end', + progressField: 'progress', moveable: true, resizable: true, labelText: '{title}' }, + minDate: '2025-01-01', maxDate: '2025-01-25', + rowHeight: 40, timelineHeader: { colWidth: 35, scales: [{ unit: 'day', step: 1 }] } + }); + }, + async exercise(page) { + // 子任务先向右移动两天,再拖动右边缘延长一天。 + const first = await page.evaluate(() => { + const rect = window.__visualTable.getTaskBarRelativeRect(1); + if (!rect) throw new Error('子任务条未渲染'); + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + rect.left + rect.width / 2, + y: host.y + rect.top + rect.height / 2 }; + }); + await page.mouse.move(first.x, first.y); + await page.mouse.down(); + await page.mouse.move(first.x + 70, first.y, { steps: 12 }); + await page.mouse.up(); + await page.evaluate(() => { + const child = window.__visualTable.records[0].children[0]; + window.__childAfterMove = { start: child.start, end: child.end }; + }); + const edge = await page.evaluate(() => { + const rect = window.__visualTable.getTaskBarRelativeRect(1); + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + rect.left + rect.width - 3, + y: host.y + rect.top + rect.height / 2 }; + }); + await page.mouse.move(edge.x, edge.y); + await page.mouse.down(); + await page.mouse.move(edge.x + 35, edge.y, { steps: 12 }); + await page.mouse.up(); + }, + async verify(page) { + // 起始日期和末日期应分别响应移动与右缘拉伸。 + await page.evaluate(() => { + const child = window.__visualTable.records[0].children[0]; + if (window.__childAfterMove.start === '2025-01-04' || + child.end === window.__childAfterMove.end) + throw new Error(`树形任务移动或拉伸未生效:${JSON.stringify(child)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/group/add-delete-records.mjs b/packages/vtable/__tests__/visual/cases/group/add-delete-records.mjs new file mode 100644 index 0000000000..70c488e851 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/group/add-delete-records.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 6787a95e54366a00b133b77a, 6787ae11863ad000b1fc4081 + * 验证目的:两级 groupBy 中按树路径插入和删除记录时分组行保持一致。 + * 改写:去除订单、客户及地址字段,使用固定 A/B 分类和短编号。 + */ +export default { + mount(container) { + // 相同二级分组有两条记录,供路径插入和删除验证。 + container.style.height = '400px'; + return new window.VTable.ListTable(container, { + columns: [{ field: 'label', title: 'Item', width: 180 }, + { field: 'value', title: 'Value', width: 120 }], + records: [ + { category: 'A', sub: 'X', label: 'Item 1', value: 10 }, + { category: 'A', sub: 'X', label: 'Item 2', value: 20 }, + { category: 'B', sub: 'Y', label: 'Item 3', value: 30 } + ], + groupBy: ['category', 'sub'], hierarchyExpandLevel: 2, + rowSeriesNumber: { title: 'No.', dragOrder: true } + }); + }, + async exercise(page) { + // 与来源一致,先在首个子分组位置插入,再删除该分组的第二条记录。 + await page.evaluate(() => { + const table = window.__visualTable; + window.__groupOriginal = JSON.stringify(table.records); + table.addRecords([{ category: 'A', sub: 'X', label: 'Added', value: 99 }], [0, 0, 0]); + window.__groupAfterAdd = JSON.stringify(table.records); + table.deleteRecords([[0, 0, 1]]); + window.__groupAfterDelete = JSON.stringify(table.records); + }); + }, + async verify(page) { + // 新增与删除均须改变二级分组内的记录顺序和数量。 + await page.evaluate(() => { + const before = JSON.parse(window.__groupOriginal)[0].children[0].children; + const afterAdd = JSON.parse(window.__groupAfterAdd)[0].children[0].children; + const afterDelete = JSON.parse(window.__groupAfterDelete)[0].children[0].children; + if (before.length !== 2 || afterAdd.length !== 3 || afterDelete.length !== 2 || + afterAdd[0].label !== 'Added' || afterDelete[0].label !== 'Added' || + afterDelete.some(record => record.label === 'Item 1')) + throw new Error(`分组增删顺序异常:${JSON.stringify({ before, afterAdd, afterDelete })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/group/one-column.mjs b/packages/vtable/__tests__/visual/cases/group/one-column.mjs new file mode 100644 index 0000000000..2fe0355be5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/group/one-column.mjs @@ -0,0 +1,25 @@ +/** + * BugServer case IDs: 69ae7bce709fdc006857f1c7 + * 验证目的:只显示一列时按隐藏字段分组,底部冻结行与主题仍正确布局。 + * 改写:移除外部 Olympic 数据请求,用同结构的固定通用记录触发分组。 + */ +export default { + mount(container) { + // country 不作为可见列,仅用于形成两个分组。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'athlete', title: 'Athlete', width: 120 }], + records: [{ country: 'A', athlete: 'Member 1' }, { country: 'A', athlete: 'Member 2' }, + { country: 'B', athlete: 'Member 3' }], + groupBy: 'country', bottomFrozenRowCount: 1, + autoWrapText: true, heightMode: 'autoHeight', widthMode: 'autoWidth', + theme: window.VTable.themes.ARCO.extends({ bottomFrozenStyle: { fontFamily: 'Arial', fontWeight: 500 } }) + }); + }, + async verify(page) { + // 分组增加标题行;表格仍只能有一列。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount !== 1 || table.rowCount <= 4) throw new Error('单列分组未展开'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/grid-tree-filter-expand.mjs b/packages/vtable/__tests__/visual/cases/header/grid-tree-filter-expand.mjs new file mode 100644 index 0000000000..0716676f01 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/grid-tree-filter-expand.mjs @@ -0,0 +1,62 @@ +/** + * BugServer case IDs: 694cee0bd8a329005c45bca3 + * 验证目的:FilterPlugin 与 headerExpandLevel 共存时,分组表头图标点击可展开下级列。 + * 改写:保留三级列、空 ID、插件与真实点击动作,字段值换成通用编号。 + */ +export default { + mount(container) { + // 第二级默认展开,最深两列需要点击折叠图标后才进入表格。 + const table = new window.VTable.ListTable(container, { + columns: [ + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', columns: [ + { field: 'name1', title: 'Name 1', width: 120 }, + { field: 'name2', title: 'Name 2', width: 150, columns: [ + { field: 'name21', title: 'Name 2.1', width: 150 }, + { field: 'name22', title: 'Name 2.2', width: 150 } + ] } + ] } + ], + records: Array.from({ length: 5 }, (_, index) => ({ id: index === 1 ? undefined : index + 1, + name: `Group ${index + 1}`, name1: `A${index + 1}`, name2: `B${index + 1}`, + name21: `C${index + 1}`, name22: `D${index + 1}` })), + headerHierarchyType: 'grid-tree', headerExpandLevel: 2, + widthMode: 'standard', defaultRowHeight: 40, + plugins: [new window.VTable.plugins.FilterPlugin({})] + }); + window.__headerColsBefore = table.colCount; + return table; + }, + async exercise(page) { + // 按场景图标边界点击真实表头折叠图标,覆盖录制的“展开表头”动作。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + for (let row = 0; row < Math.min(table.rowCount, 3); row++) { + for (let col = 0; col < table.colCount; col++) { + const stack = [table.scenegraph.getCell(col, row)]; + while (stack.length) { + const mark = stack.pop(); + if (mark?.attribute?.funcType === 'collapse') { + const bounds = mark.globalAABBBounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (bounds.x1 + bounds.x2) / 2, + y: host.y + (bounds.y1 + bounds.y2) / 2 }; + } + stack.push(...(mark?.children ?? [])); + } + } + } + throw new Error('可展开表头图标缺失'); + }); + await page.mouse.click(point.x, point.y); + await page.waitForFunction(() => window.__visualTable.colCount > window.__headerColsBefore); + }, + async verify(page) { + // 实际列数必须增加,且 FilterPlugin 仍保留在表格插件中。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount <= window.__headerColsBefore || table.options.headerExpandLevel !== 2) + throw new Error(`分组表头未展开:${window.__headerColsBefore} -> ${table.colCount}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/grid-tree-transpose.mjs b/packages/vtable/__tests__/visual/cases/header/grid-tree-transpose.mjs new file mode 100644 index 0000000000..63de07fbed --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/grid-tree-transpose.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 67f3395770dedf00a7f03228 + * 验证目的:grid-tree 分组表头与 transpose 同时启用时保持层级。 + * 改写:个人信息改为通用字段,保留转置、层级、默认表头宽度与拖动配置。 + */ +export default { + mount(container) { + // 与非转置版本使用相同的分组列,单独覆盖转置布局。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'code', title: 'Code', width: 120 }, + { title: 'Group', field: 'group', columns: [ + { field: 'first', title: 'First', width: 120 }, + { field: 'last', title: 'Last', width: 120 }] }, + { field: 'city', title: 'City', width: 120 }], + records: Array.from({ length: 10 }, (_, i) => ({ id: i + 1, code: `C${i + 1}`, + first: `First ${i + 1}`, last: `Last ${i + 1}`, city: 'City' })), + defaultHeaderColWidth: 130, headerHierarchyType: 'grid-tree', transpose: true, + widthMode: 'standard', dragHeaderMode: 'all', limitMinWidth: 20, + theme: window.VTable.themes.DEFAULT.extends({ headerStyle: { color: 'red', + frameStyle: { borderColor: 'red', borderLineWidth: 2 } } }) + }); + }, + async verify(page) { + // 转置配置必须生效并生成可读数据区。 + await page.evaluate(() => { + const table = window.__visualTable; + if (!table.options.transpose || table.options.headerHierarchyType !== 'grid-tree' || + table.colCount < 10 || table.rowCount < 4) + throw new Error('转置 grid-tree 表头未正确建立'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/grid-tree.mjs b/packages/vtable/__tests__/visual/cases/header/grid-tree.mjs new file mode 100644 index 0000000000..4d86f9edb2 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/grid-tree.mjs @@ -0,0 +1,32 @@ +/** + * BugServer case IDs: 67f33ab520aeff00a653bc6c + * 验证目的:普通列表中的 grid-tree 分组表头正常排列。 + * 改写:个人姓名、电话和邮箱改为通用字段,保留分组列和层级配置。 + */ +export default { + mount(container) { + // 顶层列与双子列并列,启用可拖动的 grid-tree 表头。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'code', title: 'Code', width: 120 }, + { title: 'Group', field: 'group', columns: [ + { field: 'first', title: 'First', width: 120 }, + { field: 'last', title: 'Last', width: 120 }] }, + { field: 'city', title: 'City', width: 120 }], + records: Array.from({ length: 10 }, (_, i) => ({ id: i + 1, code: `C${i + 1}`, + first: `First ${i + 1}`, last: `Last ${i + 1}`, city: 'City' })), + defaultColWidth: 120, headerHierarchyType: 'grid-tree', widthMode: 'standard', + dragHeaderMode: 'all', limitMinWidth: 20, + theme: window.VTable.themes.DEFAULT.extends({ headerStyle: { color: 'red', + frameStyle: { borderColor: 'red', borderLineWidth: 2 } } }) + }); + }, + async verify(page) { + // grid-tree 将分组子列折叠为一列,并保留全部记录。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.options.headerHierarchyType !== 'grid-tree' || table.colCount !== 4 || table.rowCount < 11) + throw new Error(`grid-tree 分组表头未正确建立: ${JSON.stringify({ type: table.options.headerHierarchyType, cols: table.colCount, rows: table.rowCount })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/hidden-nested.mjs b/packages/vtable/__tests__/visual/cases/header/hidden-nested.mjs new file mode 100644 index 0000000000..905bab2f07 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/hidden-nested.mjs @@ -0,0 +1,28 @@ +/** + * BugServer case IDs: 677398b4d2dacd00b3443e9d + * 验证目的:三级分组表头的末级列全部隐藏后,其余表头与数据仍可见。 + * 改写:缩减重复记录,保留两级嵌套和两个 hide 末级列。 + */ +export default { + mount(container) { + // 同一分组下保留可见列与全隐藏子分组。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, { title: 'Name', columns: [ + { field: 'first', title: 'First', width: 100 }, { title: 'Hidden group', columns: [ + { field: 'second', title: 'Second', width: 100, hide: true }, + { field: 'third', title: 'Third', width: 150, hide: true } + ] } + ] }], + records: [{ id: 1, first: 'A', second: 'B', third: 'C' }, { id: 2, first: 'D', second: 'E', third: 'F' }], + widthMode: 'standard', autoWrapText: true, autoRowHeight: true, defaultColWidth: 150 + }); + }, + async verify(page) { + // 只剩 ID 与 First 两个可见末级列。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount !== 2 || !String(table.getCellValue(1, table.columnHeaderLevelCount)).includes('A')) + throw new Error('嵌套隐藏表头未生效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/hidden-parent.mjs b/packages/vtable/__tests__/visual/cases/header/hidden-parent.mjs new file mode 100644 index 0000000000..b3cdf0d85d --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/hidden-parent.mjs @@ -0,0 +1,25 @@ +/** + * BugServer case IDs: 68998fa58af47900b375bcd4 + * 验证目的:隐藏三级分组的父节点后,同字段的独立列仍可见。 + * 改写:保留父节点 hide、重复 name2 字段和三层嵌套,缩短记录。 + */ +export default { + mount(container) { + // 隐藏整棵分组树,独立列复用被隐藏分组中的字段。 + return new window.VTable.ListTable(container, { + columns: [{ title: 'Name', hide: true, columns: [{ title: 'name-level-2', columns: [ + { field: 'name2', title: 'name2', width: 100 }, { field: 'name3', title: 'name3', width: 150 } + ] }] }, { field: 'name2', title: 'name2', width: 100 }], + records: [{ name2: 'a2', name3: 'a3' }, { name2: 'b2', name3: 'b3' }], + widthMode: 'standard', autoWrapText: true, autoRowHeight: true + }); + }, + async verify(page) { + // 隐藏父组后只显示独立 name2 列。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount !== 1 || table.getCellValue(0, table.columnHeaderLevelCount) !== 'a2') + throw new Error('隐藏父表头后独立列未保留'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/level-span.mjs b/packages/vtable/__tests__/visual/cases/header/level-span.mjs new file mode 100644 index 0000000000..5455226878 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/level-span.mjs @@ -0,0 +1,31 @@ +/** + * BugServer case IDs: 684656c47c20ee00b3e9c578 + * 验证目的:多层列表表头中的 levelSpan 跨层占位与叶列数据正常渲染。 + * 改写:缩短通用学科文字,保留四层树和两个不同的 levelSpan 值。 + */ +export default { + mount(container) { + // 保留来源的四层表头及中间节点跨层设置。 + return new window.VTable.ListTable(container, { + columns: [ + { title: 'Object', columns: [{ title: 'Field 1', columns: [ + { title: 'F11', columns: [{ field: 'math', title: 'Math', width: 150 }] }, + { title: 'F12', levelSpan: 3, columns: [{ field: 'chinese', title: 'Language', width: 150 }] }, + { title: 'F13', columns: [{ field: 'english', title: 'English', width: 150 }] } + ] }] }, + { title: 'Basic', levelSpan: 2, columns: [{ title: 'F21', columns: [{ field: 'computer', title: 'Computer', width: 150 }] }] } + ], + records: [{ math: 'math', chinese: 'language', english: 'english', computer: 'computer' }], + widthMode: 'auto', heightMode: 'autoHeight', defaultColWidth: 120, defaultRowHeight: 40, + autoWrapText: true, theme: window.VTable.themes.DEFAULT + }); + }, + async verify(page) { + // 检查四层表头与叶列数据同时存在。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.columnHeaderLevelCount < 4 || table.colCount !== 4) throw new Error('层级表头未展开'); + if (table.getCellValue(0, table.columnHeaderLevelCount) !== 'math') throw new Error('叶列数据缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/merge-cells.mjs b/packages/vtable/__tests__/visual/cases/header/merge-cells.mjs new file mode 100644 index 0000000000..584371d5fa --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/merge-cells.mjs @@ -0,0 +1,32 @@ +/** + * BugServer case IDs: 64745b3b5d221c008666ee22 + * 验证目的:格式化字段和值相同的相邻记录按列合并,并保持样式回调。 + * 改写:仅保留可触发合并的记录,保留 mergeCell 和动态样式设置。 + */ +export default { + mount(container) { + // 相邻两条 A 记录触发名称列合并,ID 格式化触发另一列合并。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'id', title: 'ID', width: 100, mergeCell: true, + fieldFormat: record => record.id === 2 ? 3 : record.id }, + { field: 'name', title: 'Name', width: 150, mergeCell: true, + style: { color: args => args.row === 1 ? 'blue' : 'red', + fontSize: args => args.row === 1 ? 16 : 20 } } + ], + records: [{ id: 2, name: 'A' }, { id: 3, name: 'A' }, { id: 4, name: 'B' }], + widthMode: 'standard' + }); + }, + async verify(page) { + // 相邻两格应返回同一合并范围,第三格保持独立。 + await page.evaluate(() => { + const table = window.__visualTable; + const first = table.getCellRange(1, 1); + const second = table.getCellRange(1, 2); + const third = table.getCellRange(1, 3); + if (first.start.row !== 1 || first.end.row !== 2 || second.end.row !== 2 || third.start.row !== 3) + throw new Error('单元格合并范围错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/row-series-number.mjs b/packages/vtable/__tests__/visual/cases/header/row-series-number.mjs new file mode 100644 index 0000000000..5d42574ccd --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/row-series-number.mjs @@ -0,0 +1,24 @@ +/** + * BugServer case IDs: 66c56702c3ab4200c6b4fe01 + * 验证目的:仅开启行序号列时仍能渲染并显示连续序号。 + * 改写:原例中的人物记录替换为固定通用记录,保留序号拖动配置。 + */ +export default { + mount(container) { + // 不配置普通数据列,保留来源的只有行序号列这一边界。 + return new window.VTable.ListTable(container, { + records: [{ name: 'A' }, { name: 'B' }, { name: 'C' }], + dragHeaderMode: 'column', + rowSeriesNumber: { enable: true, title: 'No.', field: 'name', dragOrder: true, style: { color: 'red' } } + }); + }, + async verify(page) { + // 序号列必须有实际数据单元格和有效布局。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount < 1 || table.rowCount < 4) throw new Error('序号列未创建'); + const range = table.getCellRect(0, 1)?.bounds; + if (!range || range.x2 <= range.x1) throw new Error('序号列未布局'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/header/sort-icon-hidden.mjs b/packages/vtable/__tests__/visual/cases/header/sort-icon-hidden.mjs new file mode 100644 index 0000000000..3362cf18ee --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/header/sort-icon-hidden.mjs @@ -0,0 +1,26 @@ +/** + * BugServer case IDs: 65c0882697cc3d008de5b483 + * 验证目的:列允许排序但 showSort=false 时,初始降序数据保持可读且排序图标隐藏。 + * 改写:保留 sort 与 showSort 的冲突组合,去掉重复的说明列。 + */ +export default { + mount(container) { + // 排序能力和图标可见性分别配置,视觉断言依赖截图。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, sort: true, showSort: false }, + { field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'name', title: 'Name', width: 150, sort: true }], widthMode: 'standard' + }); + table.setRecords([{ progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }, + { progress: 1, id: 3, name: 'C' }], { field: 'progress', order: 'desc' }); + return table; + }, + async verify(page) { + // 数据确实按 progress 降序,而图标是否隐藏由稳定截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 1) !== 100 || table.getCellValue(0, 3) !== 1) + throw new Error('隐藏排序图标场景排序失败'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/index.mjs b/packages/vtable/__tests__/visual/cases/index.mjs new file mode 100644 index 0000000000..1072fdd97c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/index.mjs @@ -0,0 +1,212 @@ +/** 唯一可执行清单;bundles 指定每例需要的本地构建产物。 */ +export const cases = [ + { id: 'list-basic', purpose: '列表表头、固定数据与单元格绘制', file: './table/list-basic.mjs', bundles: ['vtable'] }, + { id: 'list-select-cell', purpose: '点击单元格后选择范围确实改变', file: './table/list-select-cell.mjs', bundles: ['vtable'] }, + { id: 'pivot-analysis-two-level', purpose: '两级维度与双指标透视数据分析', file: './pivot/analysis-two-level.mjs', bundles: ['vtable'] }, + { id: 'gantt-taskbar-relative-rect', purpose: '项目子任务与空任务的相对矩形', file: './gantt/taskbar-relative-rect.mjs', bundles: ['gantt'] }, + { id: 'sheet-add-columns', purpose: 'Sheet 公式与列插入', file: './sheet/add-columns.mjs', bundles: ['sheet'] }, + { id: 'wrapped-text', purpose: '格式化文本自动换行与显式换行', file: './cells/wrapped-text.mjs', bundles: ['vtable'] }, + { id: 'checkbox-states', purpose: '复选框状态组合', file: './cells/checkbox-states.mjs', bundles: ['vtable'] }, + { id: 'mixed-sparkline-progress', purpose: '动态单元格类型与负值进度条', file: './cells/mixed-sparkline-progress.mjs', bundles: ['vtable'] }, + { id: 'switch-button', purpose: '开关和按钮普通与禁用状态', file: './cells/switch-button.mjs', bundles: ['vtable'] }, + { id: 'level-span', purpose: '多层表头 levelSpan', file: './header/level-span.mjs', bundles: ['vtable'] }, + { id: 'initial-desc', purpose: '自定义比较器与初始降序', file: './sort/initial-desc.mjs', bundles: ['vtable'] }, + { id: 'theme-arco', purpose: 'ARCO 主题与自动行高', file: './theme/arco.mjs', bundles: ['vtable'] }, + { id: 'auto-width-height', purpose: '列宽与行高自动测量', file: './layout/auto-width-height.mjs', bundles: ['vtable'] }, + { id: 'merge-cells', purpose: '相邻单元格合并与动态样式', file: './header/merge-cells.mjs', bundles: ['vtable'] }, + { id: 'plugin-filter-hide-reorder', purpose: '筛选插件与隐藏及移动列', file: './plugins/filter-hide-reorder.mjs', bundles: ['plugins'] }, + { id: 'pivot-chart-box-plot', purpose: '透视箱线图和离群点', file: './pivot-chart/box-plot.mjs', bundles: ['vchart'] }, + { id: 'row-series-number', purpose: '独立行序号列', file: './header/row-series-number.mjs', bundles: ['vtable'] }, + { id: 'scroll-bar-to-side', purpose: '滚动条贴边与多层表头', file: './scroll/bar-to-side.mjs', bundles: ['vtable'] }, + { id: 'gantt-baseline-bottom', purpose: 'Gantt 基线与三级时间轴', file: './gantt/baseline-bottom.mjs', bundles: ['gantt'] }, + { id: 'select-highlight-row', purpose: '选择高亮 row', file: './interaction/select-highlight-row.mjs', bundles: ['vtable'] }, + { id: 'select-highlight-column', purpose: '选择高亮 column', file: './interaction/select-highlight-column.mjs', bundles: ['vtable'] }, + { id: 'select-highlight-cross-theme', purpose: '选择高亮 cross 主题', file: './interaction/select-highlight-cross-theme.mjs', bundles: ['vtable'] }, + { id: 'select-highlight-cross-header-cell', purpose: '选择高亮 cross 主题 表头单格', file: './interaction/select-highlight-cross-header-cell.mjs', bundles: ['vtable'] }, + { id: 'select-highlight-cross-transpose', purpose: '选择高亮 cross 主题 表头单格 转置', file: './interaction/select-highlight-cross-transpose.mjs', bundles: ['vtable'] }, + { id: 'select-cross', purpose: 'cross 模式表体与表头连续选择', file: './interaction/select-cross.mjs', bundles: ['vtable'] }, + { id: 'list-empty-resize', purpose: '空表在缩小视口中绘制', file: './empty/list-resize.mjs', bundles: ['vtable'] }, + { id: 'edit-start-with-value', purpose: '输入编辑器启动初始值', file: './edit/start-with-value.mjs', bundles: ['editors'] }, + { id: 'pivot-corner-without-rows', purpose: '无行无指标的强制角头', file: './pivot/corner-without-rows.mjs', bundles: ['vtable'] }, + { id: 'aggregation-pagination', purpose: '合并、SUM 聚合与分页', file: './analysis/aggregation-pagination.mjs', bundles: ['vtable'] }, + { id: 'update-merged-records', purpose: '合并分组中的记录更新', file: './records/update-merged.mjs', bundles: ['vtable'] }, + { id: 'transpose-theme-header', purpose: '转置分组表头与扩展主题', file: './transpose/theme-header.mjs', bundles: ['vtable'] }, + { id: 'progress-custom-layout', purpose: '动态图标、进度条与自定义布局', file: './custom/progress-layout.mjs', bundles: ['vtable'] }, + { id: 'component-title', purpose: '标题、副标题和表头点击', file: './components/title.mjs', bundles: ['vtable'] }, + { id: 'aggregation-float', purpose: '浮点 AVG 与顶部 SUM 聚合', file: './analysis/aggregation-float.mjs', bundles: ['vtable'] }, + { id: 'filter-api', purpose: '字段与函数过滤器交集', file: './analysis/filter-api.mjs', bundles: ['vtable'] }, + { id: 'frozen-right-bottom', purpose: '左右与底部冻结边界组合', file: './frozen/right-bottom.mjs', bundles: ['vtable'] }, + { id: 'pivot-empty-tip', purpose: '无数据透视表空状态', file: './empty/pivot-tip.mjs', bundles: ['vtable'] }, + { id: 'cyrillic-wrap', purpose: '西里尔字母与混合文本换行', file: './language/cyrillic-wrap.mjs', bundles: ['vtable'] }, + { id: 'adaptive-width', purpose: 'adaptive 列宽填充', file: './layout/adaptive-width.mjs', bundles: ['vtable'] }, + { id: 'pivot-virtual-column-total', purpose: '虚拟列节点与行列合计', file: './pivot/virtual-column-total.mjs', bundles: ['vtable'] }, + { id: 'sheet-formula-edit', purpose: 'Sheet 多区域公式与编辑', file: './sheet/formula-edit.mjs', bundles: ['sheet'] }, + { id: 'gantt-baseline-top', purpose: 'Gantt 任务条上方基线', file: './gantt/baseline-top.mjs', bundles: ['gantt'] }, + { id: 'tree-list-merge-expand', purpose: '树形列表与合并列展开', file: './tree/list-merge-expand.mjs', bundles: ['vtable'] }, + { id: 'sort-update-state', purpose: 'setRecords 后切换排序状态', file: './sort/update-state.mjs', bundles: ['vtable'] }, + { id: 'records-add-merged', purpose: '合并分组插入记录', file: './records/add-merged.mjs', bundles: ['vtable'] }, + { id: 'records-delete-merged', purpose: '合并分组删除记录', file: './records/delete-merged.mjs', bundles: ['vtable'] }, + { id: 'nested-field', purpose: '数组字段路径与缺失值', file: './data/nested-field.mjs', bundles: ['vtable'] }, + { id: 'promise-source', purpose: '异步 CachedDataSource 批次加载', file: './data/promise-source.mjs', bundles: ['vtable'] }, + { id: 'plugin-highlight-header', purpose: '透视表选择后的行列表头高亮', file: './plugins/highlight-header.mjs', bundles: ['plugins'] }, + { id: 'pivot-update-filter', purpose: '透视表过滤规则更新', file: './pivot/update-filter.mjs', bundles: ['vtable'] }, + { id: 'inline-image', purpose: '图片单元格边缘点击与自动尺寸', file: './cells/inline-image.mjs', bundles: ['vtable'] }, + { id: 'hover-tooltip', purpose: '多单元格悬停提示更新', file: './components/hover-tooltip.mjs', bundles: ['vtable'] }, + { id: 'menu-disabled-item', purpose: '下拉子菜单禁用项', file: './menu/disabled-item.mjs', bundles: ['vtable'] }, + { id: 'pivot-indicator-sort-rule', purpose: '透视指标排序规则和背景映射', file: './pivot/indicator-sort-rule.mjs', bundles: ['vtable'] }, + { id: 'pivot-custom-sort-click-colors', purpose: '透视自定义比较器点击排序及双指标着色', file: './pivot/custom-sort-click-colors.mjs', bundles: ['vtable'] }, + { id: 'key-size-config', purpose: '稳定 key 的列宽行高配置', file: './layout/key-size-config.mjs', bundles: ['vtable'] }, + { id: 'style-dashed-border', purpose: '虚线双色单元格边框', file: './style/dashed-border.mjs', bundles: ['vtable'] }, + { id: 'group-one-column', purpose: '隐藏分组字段的单列列表', file: './group/one-column.mjs', bundles: ['vtable'] }, + { id: 'api-update-columns', purpose: '悬停时更新列', file: './api/update-columns.mjs', bundles: ['vtable'] }, + { id: 'disable-hover-columns', purpose: '列级禁用表体和表头悬停', file: './interaction/disable-hover-columns.mjs', bundles: ['vtable'] }, + { id: 'select-highlight-in-range', purpose: '跨表头与表体拖选范围高亮', file: './interaction/select-highlight-in-range.mjs', bundles: ['vtable'] }, + { id: 'select-header-scroll-top', purpose: '滚动后固定表头仍可选择', file: './interaction/select-header-scroll-top.mjs', bundles: ['vtable'] }, + { id: 'api-update-columns-on-mouseleave', purpose: '鼠标离开单元格回调中更新列', file: './api/update-columns-on-mouseleave.mjs', bundles: ['vtable'] }, + { id: 'api-update-columns-after-selection', purpose: '点击选中时更新列并保持选区', file: './api/update-columns-after-selection.mjs', bundles: ['vtable'] }, + { id: 'layout-auto-fill-width-resize-container', purpose: '自动填宽下拖列边界和调整容器', file: './layout/auto-fill-width-resize-container.mjs', bundles: ['vtable'] }, + { id: 'double-click-auto-column-width', purpose: '拖宽后双击边界自动测量列宽', file: './interaction/double-click-auto-column-width.mjs', bundles: ['vtable'] }, + { id: 'double-click-auto-width-height', purpose: '自动行高换行时双击测量列宽', file: './interaction/double-click-auto-width-height.mjs', bundles: ['vtable'] }, + { id: 'plugin-auto-fill-grid-tree-origin-value', purpose: '树形表头填充柄拖动复制原值', file: './plugins/auto-fill-grid-tree-origin-value.mjs', bundles: ['plugins'] }, + { id: 'checkbox-over-thousand-rows', purpose: '千行外滚动与冻结复选格点击', file: './cells/checkbox-over-thousand-rows.mjs', bundles: ['vtable'] }, + { id: 'pivot-update-sort-rules-after-click', purpose: '透视排序图标点击后替换行列规则', file: './pivot/update-sort-rules-after-click.mjs', bundles: ['vtable'] }, + { id: 'pivot-chart-brush-multiple-selection', purpose: '双透视柱状图 multiple 框选拖动', file: './pivot-chart/brush-multiple-selection.mjs', bundles: ['vchart'] }, + { id: 'api-change-cell-values', purpose: '二维批量更新单元格值', file: './api/change-cell-values.mjs', bundles: ['vtable'] }, + { id: 'pivot-wide-scrollbar', purpose: '透视表宽滚动条与主题条纹', file: './scroll/pivot-wide-rail.mjs', bundles: ['vtable'] }, + { id: 'edit-complete-input', purpose: '输入编辑器完成提交', file: './edit/complete-input.mjs', bundles: ['editors'] }, + { id: 'select-disabled-column-range', purpose: '拖选不能覆盖禁选列', file: './interaction/disabled-column-range.mjs', bundles: ['vtable'] }, + { id: 'sheet-add-rows', purpose: 'Sheet 行高调整与插入记录', file: './sheet/add-rows.mjs', bundles: ['sheet'] }, + { id: 'sheet-insert-columns-before-second', purpose: 'Sheet 第二列前插入两列', file: './sheet/insert-columns-before-second.mjs', bundles: ['sheet'] }, + { id: 'sheet-insert-columns-before-third', purpose: 'Sheet 第三列前插入两列', file: './sheet/insert-columns-before-third.mjs', bundles: ['sheet'] }, + { id: 'sheet-insert-rows-at-start', purpose: 'Sheet 第一条记录前插入两行', file: './sheet/insert-rows-at-start.mjs', bundles: ['sheet'] }, + { id: 'sheet-insert-rows-after-first', purpose: 'Sheet 第二条记录前插入两行', file: './sheet/insert-rows-after-first.mjs', bundles: ['sheet'] }, + { id: 'pivot-no-indicators', purpose: '无指标透视表与链接型维度表头', file: './pivot/no-indicators.mjs', bundles: ['vtable'] }, + { id: 'keyboard-select-all-body', purpose: '快捷键全选表体且排除表头', file: './keyboard/select-all-body.mjs', bundles: ['vtable'] }, + { id: 'pivot-tree-zero-expand', purpose: '数字零值透视树节点展开', file: './pivot/tree-zero-expand.mjs', bundles: ['vtable'] }, + { id: 'plugin-master-detail-auto-height', purpose: '主从表自动高度与滚动', file: './plugins/master-detail-auto-height.mjs', bundles: ['plugins'] }, + { id: 'frozen-one-column-left', purpose: '唯一列左冻结边界', file: './frozen/one-column-left.mjs', bundles: ['vtable'] }, + { id: 'frozen-one-column-right', purpose: '唯一列右冻结边界', file: './frozen/one-column-right.mjs', bundles: ['vtable'] }, + { id: 'frozen-top-bottom-border', purpose: '上下冻结行与四边框', file: './frozen/top-bottom-border.mjs', bundles: ['vtable'] }, + { id: 'header-hidden-nested', purpose: '三级分组表头末级列隐藏', file: './header/hidden-nested.mjs', bundles: ['vtable'] }, + { id: 'style-border-clip-direction', purpose: '单元格边框裁剪方向', file: './style/border-clip-direction.mjs', bundles: ['vtable'] }, + { id: 'records-delete-all-bottom-frozen', purpose: '删除全部记录后底部冻结区重置', file: './records/delete-all-bottom-frozen.mjs', bundles: ['vtable'] }, + { id: 'records-array-path-add', purpose: '数组记录字段路径与插入', file: './records/array-path-add.mjs', bundles: ['vtable'] }, + { id: 'layout-container-fit-height', purpose: 'autoWidth 与容器高度适配', file: './layout/container-fit-height.mjs', bundles: ['vtable'] }, + { id: 'pivot-calculated-field-hidden-dependencies', purpose: '未显示依赖指标的计算字段', file: './pivot/calculated-field-hidden-dependencies.mjs', bundles: ['vtable'] }, + { id: 'gantt-project-range-set-records', purpose: 'setRecords 项目时间边界重算', file: './gantt/project-range-set-records.mjs', bundles: ['gantt'] }, + { id: 'gantt-empty-mark-line', purpose: '无任务的固定日期标记线', file: './gantt/empty-mark-line.mjs', bundles: ['gantt'] }, + { id: 'gantt-resize-task-list-header', purpose: 'Gantt 缩窄任务列表列头后双层时间轴保持布局', file: './gantt/resize-task-list-header.mjs', bundles: ['gantt'] }, + { id: 'gantt-baseline-overlap', purpose: '基线与任务条重叠布局', file: './gantt/baseline-overlap.mjs', bundles: ['gantt'] }, + { id: 'pivot-chart-heatmap-two-indicators', purpose: '双热力图数据流和颜色图例', file: './pivot-chart/heatmap-two-indicators.mjs', bundles: ['vchart'] }, + { id: 'sheet-update-option', purpose: 'Sheet 全量配置更新', file: './sheet/update-option.mjs', bundles: ['sheet'] }, + { id: 'pivot-chart-box-plot-horizontal', purpose: '水平箱线图与离群点', file: './pivot-chart/box-plot-horizontal.mjs', bundles: ['vchart'] }, + { id: 'pivot-chart-histogram-overlap', purpose: '重叠区间透视直方图', file: './pivot-chart/histogram-overlap.mjs', bundles: ['vchart'] }, + { id: 'theme-dark-icons', purpose: '深色主题的冻结与排序图标', file: './theme/dark-icons.mjs', bundles: ['vtable'] }, + { id: 'plugin-fill-handle-hit-area', purpose: '填充柄视觉大小和命中范围', file: './plugins/fill-handle-hit-area.mjs', bundles: ['vtable'] }, + { id: 'pivot-update-option-row-header-width', purpose: '透视配置更新后行头列宽', file: './pivot/update-option-row-header-width.mjs', bundles: ['vtable'] }, + { id: 'pivot-custom-merge-subtotal', purpose: '行头自定义合并和小计', file: './pivot/custom-merge-subtotal.mjs', bundles: ['vtable'] }, + { id: 'sheet-copy-paste', purpose: 'Sheet 公式结果事件复制粘贴', file: './sheet/copy-paste.mjs', bundles: ['sheet'] }, + { id: 'keyboard-copy-paste-event', purpose: '列表单格事件复制粘贴', file: './keyboard/copy-paste-event.mjs', bundles: ['vtable'] }, + { id: 'sort-multiple-header-click', purpose: '多字段排序图标连续点击', file: './sort/multiple-header-click.mjs', bundles: ['vtable'] }, + { id: 'pivot-chart-scatter-axes', purpose: 'bar 与 scatter 独立轴', file: './pivot-chart/scatter-axes.mjs', bundles: ['vchart'] }, + { id: 'theme-update-theme', purpose: 'ARCO 主题运行时更新', file: './theme/update-theme.mjs', bundles: ['vtable'] }, + { id: 'header-sort-icon-hidden', purpose: '可排序列隐藏排序图标', file: './header/sort-icon-hidden.mjs', bundles: ['vtable'] }, + { id: 'plugin-context-menu-click', purpose: '上下文菜单冻结事件', file: './plugins/context-menu-click.mjs', bundles: ['plugins'] }, + { id: 'layout-container-fit-both', purpose: 'autoWidth 与布尔容器适配', file: './layout/container-fit-both.mjs', bundles: ['vtable'] }, + { id: 'header-hidden-parent', purpose: '隐藏父表头保留独立同字段列', file: './header/hidden-parent.mjs', bundles: ['vtable'] }, + { id: 'style-underline', purpose: '下划线变体与虚线边框', file: './style/underline.mjs', bundles: ['vtable'] }, + { id: 'transpose-update-option-off', purpose: '转置记录更新后关闭转置', file: './transpose/update-option-off.mjs', bundles: ['vtable'] }, + { id: 'records-add-sorted', purpose: '排序状态下批量插入缺字段记录', file: './records/add-sorted.mjs', bundles: ['vtable'] }, + { id: 'records-delete-sorted', purpose: '排序状态下双索引删除记录', file: './records/delete-sorted.mjs', bundles: ['vtable'] }, + { id: 'component-legend-resize', purpose: '单行图例和标题随容器缩窄', file: './components/legend-resize.mjs', bundles: ['vtable'] }, + { id: 'frozen-short-content-connected', purpose: '短内容上下冻结区连接边界', file: './frozen/short-content-connected.mjs', bundles: ['vtable'] }, + { id: 'tree-checkbox-set-state', purpose: '树节点复选状态设置且不级联', file: './tree/checkbox-set-state.mjs', bundles: ['vtable'] }, + { id: 'pivot-indicator-sort-subtotal', purpose: '指标路径排序与行维度小计', file: './pivot/indicator-sort-subtotal.mjs', bundles: ['vtable'] }, + { id: 'pivot-aggregation-rules', purpose: 'SUM COUNT AVG 三类聚合指标', file: './pivot/aggregation-rules.mjs', bundles: ['vtable'] }, + { id: 'header-grid-tree', purpose: '普通列表 grid-tree 分组表头', file: './header/grid-tree.mjs', bundles: ['vtable'] }, + { id: 'header-grid-tree-transpose', purpose: '转置 grid-tree 分组表头', file: './header/grid-tree-transpose.mjs', bundles: ['vtable'] }, + { id: 'menu-dropdown-highlight', purpose: '列下拉菜单状态图标与高亮', file: './menu/dropdown-highlight.mjs', bundles: ['vtable'] }, + { id: 'gantt-baseline-padding-static', purpose: 'Gantt 任务条和基线的固定纵向间距', file: './gantt/baseline-padding-static.mjs', bundles: ['gantt'] }, + { id: 'gantt-baseline-padding-function', purpose: 'Gantt 任务索引驱动的动态基线间距', file: './gantt/baseline-padding-function.mjs', bundles: ['gantt'] }, + { id: 'pivot-chart-mark-line', purpose: '柱状图汇总标记线与端点符号', file: './pivot-chart/mark-line.mjs', bundles: ['vchart'] }, + { id: 'pivot-chart-combination-forecast', purpose: '双指标历史柱趋势线预测柱组合图', file: './pivot-chart/combination-forecast.mjs', bundles: ['vchart'] }, + { id: 'pivot-chart-sunburst-hierarchy', purpose: '嵌套层级数据旭日图', file: './pivot-chart/sunburst-hierarchy.mjs', bundles: ['vchart'] }, + { id: 'header-grid-tree-filter-expand', purpose: '筛选插件与三级树形表头点击展开', file: './header/grid-tree-filter-expand.mjs', bundles: ['plugins'] }, + { id: 'promise-custom-merge', purpose: '异步批次数据中的多行多列自定义合并', file: './data/promise-custom-merge.mjs', bundles: ['vtable'] }, + { id: 'tree-checkbox-add-record', purpose: '树形行序号复选状态下插入根记录', file: './tree/checkbox-add-record.mjs', bundles: ['vtable'] }, + { id: 'tree-checkbox-delete-record', purpose: '树形行序号复选状态下删除混合路径', file: './tree/checkbox-delete-record.mjs', bundles: ['vtable'] }, + { id: 'sheet-cross-tab-formula', purpose: '跨 Sheet 求和与中文感叹号归一化', file: './sheet/cross-tab-formula.mjs', bundles: ['sheet'] }, + { id: 'drag-column-header', purpose: '多层表头旁普通列鼠标拖动排序', file: './interaction/drag-column-header.mjs', bundles: ['vtable'] }, + { id: 'tree-lazy-record-children', purpose: '展开树节点后事件注入懒加载子记录', file: './tree/lazy-record-children.mjs', bundles: ['vtable'] }, + { id: 'records-add-pagination', purpose: '分页首屏内批量插入记录', file: './records/add-pagination.mjs', bundles: ['vtable'] }, + { id: 'pivot-update-sort-state-click', purpose: '透视排序点击事件回写专用状态', file: './pivot/update-pivot-sort-state-click.mjs', bundles: ['vtable'] }, + { id: 'pivot-chart-histogram-color', purpose: '直方图区间颜色域与零值柱', file: './pivot-chart/histogram-color.mjs', bundles: ['vchart'] }, + { id: 'gantt-locate-offscreen-task', purpose: 'Gantt 左右离屏任务定位图标点击滚动', file: './gantt/locate-offscreen-task.mjs', bundles: ['gantt'] }, + { id: 'tree-pagination-double-click', purpose: '树形列表第二页双击事件定位', file: './tree/pagination-double-click.mjs', bundles: ['vtable'] }, + { id: 'sheet-formula-auto-fill-copy', purpose: 'Sheet 公式填充柄拖动和复制填充菜单', file: './sheet/formula-auto-fill-copy.mjs', bundles: ['sheet'] }, + { id: 'select-disable-callback', purpose: '回调按行列禁选单元格', file: './interaction/disable-select-callback.mjs', bundles: ['vtable'] }, + { id: 'gantt-move-task-bar', purpose: 'Gantt 任务条拖动与日期事件', file: './gantt/move-task-bar.mjs', bundles: ['gantt'] }, + { id: 'pivot-initial-column-sort-state', purpose: '透视列树分支初始排序状态与自动填宽', file: './pivot/initial-column-sort-state.mjs', bundles: ['vtable'] }, + { id: 'blank-area-deselect', purpose: '空白区清除选择的双配置切换', file: './interaction/blank-area-deselect.mjs', bundles: ['vtable'] }, + { id: 'disabled-arrow-navigation', purpose: '禁选列与键盘方向导航', file: './keyboard/disabled-arrow-navigation.mjs', bundles: ['vtable'] }, + { id: 'pivot-custom-total-data-tree', purpose: '显式汇总记录的透视树展开', file: './pivot/custom-total-data-tree.mjs', bundles: ['vtable'] }, + { id: 'promise-keep-data', purpose: '异步数据源配置更新时保留数据', file: './data/promise-keep-data.mjs', bundles: ['vtable'] }, + { id: 'pivot-corner-empty-matrix', purpose: '透视表空行空列空指标角头矩阵', file: './pivot/corner-empty-matrix.mjs', bundles: ['vtable'] }, + { id: 'pivot-chart-bar-line-right-axis', purpose: '透视柱线图百分比柱宽与左右双轴', file: './pivot-chart/bar-line-right-axis.mjs', bundles: ['vchart'] }, + { id: 'tree-filter-clear-lazy', purpose: '树形列表过滤、懒加载与清除规则', file: './tree/filter-clear-lazy.mjs', bundles: ['vtable'] }, + { id: 'gantt-tree-move-resize', purpose: '树形子任务条移动与拉伸', file: './gantt/tree-move-resize.mjs', bundles: ['gantt'] }, + { id: 'group-add-delete-records', purpose: '两级分组内按路径增删记录', file: './group/add-delete-records.mjs', bundles: ['vtable'] }, + { id: 'drag-row-series-select', purpose: '行序号列纵向拖选', file: './interaction/drag-row-series-select.mjs', bundles: ['vtable'] }, + { id: 'drag-row-series-order', purpose: '行序号两次拖动重排记录', file: './interaction/drag-row-series-order.mjs', bundles: ['vtable'] }, + { id: 'keyboard-cut-paste-event', purpose: '事件剪贴板剪切两格并粘贴', file: './keyboard/cut-paste-event.mjs', bundles: ['vtable'] }, + { id: 'pivot-empty-string-dimension', purpose: '透视空字符串列维度与普通列并存', file: './pivot/empty-string-dimension.mjs', bundles: ['vtable'] }, + { id: 'pivot-chart-pie-multi-select', purpose: '透视饼图连续扇区多选', file: './pivot-chart/pie-multi-select.mjs', bundles: ['vchart'] }, + { id: 'resize-row-height', purpose: '列表选择后拖动行边界调整行高', file: './interaction/resize-row-height.mjs', bundles: ['vtable'] }, + { id: 'pivot-resize-indicator-row', purpose: '指标行上的透视表拖动行高', file: './pivot/resize-indicator-row.mjs', bundles: ['vtable'] }, + { id: 'pivot-corner-sort-one-dimension', purpose: '单级角头维度初始降序状态与排序图标', file: './pivot/corner-sort-one-dimension.mjs', bundles: ['vtable'] }, + { id: 'pivot-corner-sort-two-dimensions', purpose: '两级角头维度初始降序状态与排序图标', file: './pivot/corner-sort-two-dimensions.mjs', bundles: ['vtable'] }, + { id: 'pivot-chart-circle-packing-hierarchy', purpose: '透视圆形打包图层级数据', file: './pivot-chart/circle-packing-hierarchy.mjs', bundles: ['vchart'] }, + { id: 'layout-min-max-adaptive-width', purpose: '自适应列宽的最小最大值', file: './layout/min-max-adaptive-width.mjs', bundles: ['vtable'] }, + { id: 'pivot-max-frozen-width', purpose: '透视冻结行头宽度上限逐列裁剪', file: './frozen/pivot-max-frozen-width.mjs', bundles: ['vtable'] }, + { id: 'pivot-chart-crosshair-linkage-hover', purpose: '横向图表悬停准线联动', file: './pivot-chart/crosshair-linkage-hover.mjs', bundles: ['vchart'] }, + { id: 'pivot-column-header-title', purpose: '透视列表头独立标题行', file: './pivot/column-header-title.mjs', bundles: ['vtable'] }, + { id: 'sort-initial-multiple-state', purpose: 'setRecords 前设置复合初始排序', file: './sort/initial-multiple-state.mjs', bundles: ['vtable'] }, + { id: 'layout-limit-max-auto-width', purpose: '自动列宽最大值开关与数值限制', file: './layout/limit-max-auto-width.mjs', bundles: ['vtable'] }, + { id: 'layout-custom-compute-row-height', purpose: '按记录回调计算行高', file: './layout/custom-compute-row-height.mjs', bundles: ['vtable'] }, + { id: 'frozen-drag-disabled', purpose: '冻结列表头禁拖但普通列仍可移动', file: './frozen/drag-disabled.mjs', bundles: ['vtable'] }, + { id: 'frozen-drag-adjust-count', purpose: '拖动普通列进入冻结区并调整冻结列数', file: './frozen/drag-adjust-count.mjs', bundles: ['vtable'] }, + { id: 'frozen-scroll-columns', purpose: '左右冻结区独立横向滚动', file: './frozen/scroll-frozen-columns.mjs', bundles: ['vtable'] }, + { id: 'tree-checkbox-record-index-clear', purpose: '隐藏树子节点索引取消勾选后清空整列', file: './tree/checkbox-record-index-clear.mjs', bundles: ['vtable'] }, + { id: 'layout-canvas-size-options', purpose: '固定自动限制及更新后的画布尺寸', file: './layout/canvas-size-options.mjs', bundles: ['vtable'] }, + { id: 'tree-zero-height-toggle', purpose: '零高度树子行折叠展开后视口填充', file: './tree/zero-height-toggle.mjs', bundles: ['vtable'] }, + { id: 'pivot-corner-remaining-variants', purpose: '角头其余三种行列指标排列', file: './pivot/corner-remaining-variants.mjs', bundles: ['vtable'] }, + { id: 'pivot-resize-indicator-group', purpose: '指标组行高按比例一起拖动', file: './pivot/resize-indicator-group.mjs', bundles: ['vtable'] }, + { id: 'transpose-only-body-auto-width', purpose: '转置表仅按表体计算自动列宽', file: './transpose/only-body-auto-width.mjs', bundles: ['vtable'] }, + { id: 'hover-cross', purpose: '表头与表体悬停的 cross 高亮模式', file: './interaction/hover-cross.mjs', bundles: ['vtable'] }, + { id: 'hover-row', purpose: '表头与表体悬停的 row 高亮模式', file: './interaction/hover-row.mjs', bundles: ['vtable'] }, + { id: 'hover-column', purpose: '表头与表体悬停的 column 高亮模式', file: './interaction/hover-column.mjs', bundles: ['vtable'] }, + { id: 'hover-single', purpose: '表头与表体悬停的 single 高亮模式', file: './interaction/hover-single.mjs', bundles: ['vtable'] }, + { id: 'select-drag-outside', purpose: '连续单选拖选表头选择与表外清选', file: './interaction/select-drag-outside.mjs', bundles: ['vtable'] }, + { id: 'select-header-cell', purpose: '表头 cell 模式单格选择', file: './interaction/select-header-cell.mjs', bundles: ['vtable'] }, + { id: 'select-column-disabled', purpose: '列级表体与表头禁选', file: './interaction/select-column-disabled.mjs', bundles: ['vtable'] }, + { id: 'select-global-disabled', purpose: '全局禁选表体与表头', file: './interaction/select-global-disabled.mjs', bundles: ['vtable'] }, + { id: 'select-header-disabled-drag', purpose: '表头禁选下的表体选择与列拖动', file: './interaction/select-header-disabled-drag.mjs', bundles: ['vtable'] }, + { id: 'frozen-shadow-start', purpose: '冻结列 overflow 阴影在 start 位置的可见性', file: './frozen/frozen-shadow-start.mjs', bundles: ['vtable'] }, + { id: 'frozen-shadow-end', purpose: '冻结列 overflow 阴影在 end 位置的可见性', file: './frozen/frozen-shadow-end.mjs', bundles: ['vtable'] }, + { id: 'frozen-shadow-middle', purpose: '冻结列 overflow 阴影在 middle 位置的可见性', file: './frozen/frozen-shadow-middle.mjs', bundles: ['vtable'] }, + { id: 'scrollbar-ignore-frozen', purpose: '主横向滚动条跨越冻结列宽度', file: './frozen/scrollbar-ignore-frozen.mjs', bundles: ['vtable'] }, + { id: 'frozen-empty-records-header', purpose: '大量列滚动后清空记录保留冻结表头', file: './frozen/empty-records-header.mjs', bundles: ['vtable'] }, + { id: 'checkbox-header-options', purpose: '复选框表头回调固定选中与禁用配置', file: './cells/checkbox-header-options.mjs', bundles: ['vtable'] }, + { id: 'link-merged-auto-height', purpose: '合并链接单元格在自动行高下禁用点击跳转', file: './cells/link-merged-auto-height.mjs', bundles: ['vtable'] }, + { id: 'pivot-indicator-hide-frozen', purpose: '按列隐藏透视指标并冻结右列与底行', file: './pivot/indicator-hide-frozen.mjs', bundles: ['vtable'] }, + { id: 'pivot-color-legend-order', purpose: '透视表标题先于顶部连续色彩图例布局', file: './pivot/color-legend-order.mjs', bundles: ['vtable'] }, + { id: 'pivot-chart-spec-mark-line', purpose: '透视表回调图表配置及面积图阈值标记线', file: './pivot/chart-spec-mark-line.mjs', bundles: ['vchart'] }, + { id: 'pivot-chart-area-axis-sort', purpose: '启用坐标轴排序配置的双系列透视面积图', file: './pivot-chart/area-axis-sort.mjs', bundles: ['vchart'] }, + { id: 'pivot-chart-rose-indicator-title', purpose: '透视玫瑰图与可见指标标题', file: './pivot-chart/rose-indicator-title.mjs', bundles: ['vchart'] }, + { id: 'plugin-excel-delete-range', purpose: 'Excel 键盘插件通过 Delete 清空可编辑选区', file: './plugins/excel-delete-range.mjs', bundles: ['plugins', 'editors'] }, +]; + +/** 从当前清单目录加载用例,冻结副本不会回到工作区取代码。 */ +export async function loadCase(item) { + return (await import(new URL(item.file, import.meta.url))).default; +} diff --git a/packages/vtable/__tests__/visual/cases/interaction/blank-area-deselect.mjs b/packages/vtable/__tests__/visual/cases/interaction/blank-area-deselect.mjs new file mode 100644 index 0000000000..568afeff9d --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/blank-area-deselect.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 667012bf71989000cfc6dc4d, 6670130571989000cfc6dc4f + * 验证目的:空白区点击是否清除选择,由 blankAreaClickDeselect 的真假控制。 + * 改写:使用固定匿名记录,分别在配置更新前后验证选择状态。 + */ +export default { + mount(container) { + // 短表在 400px 容器中留下可点击的空白区域。 + container.style.height = '400px'; + const records = [{ id: 1, name: 'Item A' }, { id: 2, name: 'Item B' }]; + const columns = [{ field: 'id', title: 'ID', width: 120 }, + { field: 'name', title: 'Name', width: 180 }]; + window.__blankOptions = { records, columns }; + return new window.VTable.ListTable(container, { + records, columns, select: { blankAreaClickDeselect: true }, widthMode: 'standard' + }); + }, + async exercise(page) { + // 先验证默认清除行为,再将配置改为 false 并重复点击。 + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(1, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { cell: { x: host.x + (b.x1 + b.x2) / 2, + y: host.y + (b.y1 + b.y2) / 2 }, + blank: { x: host.x + 220, y: host.y + 300 } }; + }); + await page.mouse.click(point.cell.x, point.cell.y); + await page.mouse.click(point.blank.x, point.blank.y); + await page.evaluate(() => { + if (window.__visualTable.getSelectedCellRanges().length) + throw new Error('blankAreaClickDeselect=true 未清除选择'); + window.__visualTable.updateOption({ ...window.__blankOptions, + select: { blankAreaClickDeselect: false }, widthMode: 'standard' }); + }); + await page.mouse.click(point.cell.x, point.cell.y); + await page.mouse.click(point.blank.x, point.blank.y); + }, + async verify(page) { + // false 时点击表格空白处仍保留最后选择的单元格。 + await page.evaluate(() => { + const ranges = window.__visualTable.getSelectedCellRanges(); + if (ranges.length !== 1 || ranges[0].start.col !== 1 || ranges[0].start.row !== 1) + throw new Error(`空白区错误清除了选择:${JSON.stringify(ranges)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/disable-hover-columns.mjs b/packages/vtable/__tests__/visual/cases/interaction/disable-hover-columns.mjs new file mode 100644 index 0000000000..1a064c386b --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/disable-hover-columns.mjs @@ -0,0 +1,43 @@ +/** + * BugServer case IDs: 6584086d807cf6008d99baad + * 验证目的:列级禁用表体和表头悬停,高亮仍可在普通列显示。 + * 改写:固定匿名记录,按实际单元格矩形重放两个录制的悬停动作。 + */ +export default { + mount(container) { + // 第一列禁用表体悬停,第二列禁用表头悬停,第三列作为正常对照。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, disableHover: true }, + { field: 'id', title: 'ID', width: 110, disableHeaderHover: true }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 20, id: 1, name: 'A' }, { progress: 40, id: 2, name: 'B' }], + hover: { highlightMode: 'cross', disableHover: false }, + theme: { bodyStyle: { hover: { cellBgColor: '#B9D5FF' } }, + headerStyle: { hover: { cellBgColor: '#B9D5FF' } } } + }); + }, + async exercise(page) { + // 依次悬停普通列、禁用表头和禁用表体,最后保留禁用表体供截图比较。 + const point = (col, row) => page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + for (const [col, row] of [[2, 1], [1, 0], [0, 2]]) { + const p = await point(col, row); + await page.mouse.move(p.x, p.y); + } + }, + async verify(page) { + // 真实指针位置与两类列级禁用项同时成立,外观由截图比对。 + await page.evaluate(() => { + const table = window.__visualTable; + const hover = table.stateManager.hover; + if (hover.highlightScope !== 'cross' || hover.cellPos.col !== 0 || hover.cellPos.row !== 2 || + !table.getBodyColumnDefine(0, 2).disableHover || !table.getHeaderDefine(1, 0).disableHeaderHover) + throw new Error('列级悬停禁用场景无效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/disable-select-callback.mjs b/packages/vtable/__tests__/visual/cases/interaction/disable-select-callback.mjs new file mode 100644 index 0000000000..9eb8f2b835 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/disable-select-callback.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 6747e56a71189400b2ec8dc7 + * 验证目的:disableSelect 回调同时禁选指定行和列,其余单元格可选择。 + * 改写:去掉格式化、排序和主题设置,保留源用例的行列禁选规则与点击顺序。 + */ +export default { + mount(container) { + // 第 2 行和第 2 列由同一个回调决定禁选状态。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 130 }, + { field: 'status', title: 'Status', width: 130 }, + { field: 'progress', title: 'Progress', width: 130 }], + records: Array.from({ length: 5 }, (_, index) => ({ id: index + 1, + name: `Item ${index + 1}`, status: 'Ready', progress: (index + 1) * 20 })), + select: { disableSelect(col, row) { return row === 2 || col === 2; } } + }); + }, + async exercise(page) { + // 依次点击禁选行、禁选列和普通单元格,记录每一步的选择范围。 + const points = await page.evaluate(() => { + const table = window.__visualTable; + const host = document.getElementById('table').getBoundingClientRect(); + return [[0, 2], [2, 3], [0, 3]].map(([col, row]) => { + const bounds = table.getCellRect(col, row).bounds; + return { x: host.x + (bounds.x1 + bounds.x2) / 2, + y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }); + }); + await page.evaluate(() => { window.__selectSnapshots = []; }); + for (const point of points) { + await page.mouse.click(point.x, point.y); + await page.evaluate(() => window.__selectSnapshots.push(window.__visualTable.getSelectedCellRanges())); + } + }, + async verify(page) { + // 前两个点击不能选择禁选区域,最后一次应选中普通单元格。 + await page.evaluate(() => { + const [row, col, allowed] = window.__selectSnapshots; + if (row.length || col.length || allowed.length !== 1 || + allowed[0].start.col !== 0 || allowed[0].start.row !== 3) + throw new Error(`disableSelect 回调未生效:${JSON.stringify(window.__selectSnapshots)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/disabled-column-range.mjs b/packages/vtable/__tests__/visual/cases/interaction/disabled-column-range.mjs new file mode 100644 index 0000000000..24b072db6a --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/disabled-column-range.mjs @@ -0,0 +1,51 @@ +/** + * BugServer case IDs: 69cf25dde41b43005d89a623 + * 验证目的:拖选跨过禁选列时,选择范围与高亮不会覆盖该列。 + * 改写:保留 inline 表头选择、cross 高亮和禁选列,使用单元格矩形代替录制像素坐标。 + */ +export default { + mount(container) { + // 固定四列及五行数据,最后一列明确禁止选择。 + container.style.width = '800px'; + container.style.height = '400px'; + return new window.VTable.ListTable(container, { + select: { headerSelectMode: 'inline', highlightMode: 'cross', highlightInRange: true }, + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'note', title: 'Note', width: 150 }, + { field: 'name', title: 'Name', width: 150, disableSelect: true } + ], + records: [ + { progress: 100, id: 1, note: 'A1', name: 'A' }, + { progress: 80, id: 2, note: 'B2', name: 'B' }, + { progress: 1, id: 3, note: 'C3', name: 'C' }, + { progress: 55, id: 4, note: 'D4', name: 'D' }, + { progress: 28, id: 5, note: 'E5', name: 'E' } + ], + showPin: true, widthMode: 'standard', allowFrozenColCount: 2 + }); + }, + async exercise(page) { + // 复现来源中从首列拖至禁选列的动作。 + const points = await page.evaluate(() => { + const host = document.getElementById('table').getBoundingClientRect(); + return [[0, 1], [3, 4]].map(([col, row]) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + }); + await page.mouse.move(points[0].x, points[0].y); + await page.mouse.down(); + await page.mouse.move(points[1].x, points[1].y, { steps: 8 }); + await page.mouse.up(); + }, + async verify(page) { + // 至少选择一个单元格,并检查范围没有进入禁选列。 + await page.evaluate(() => { + const ranges = window.__visualTable.getSelectedCellRanges(); + if (!ranges.length || ranges.some(range => Math.max(range.start.col, range.end.col) >= 3)) + throw new Error(`禁选列进入选择范围:${JSON.stringify(ranges)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-column-width.mjs b/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-column-width.mjs new file mode 100644 index 0000000000..22f9242077 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-column-width.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 6555e6399dabd71aa3845bb7 + * 验证目的:拖宽列后双击列边界,列宽按内容重新计算。 + * 改写:用匿名文本代替原始记录,按当前单元格边界计算鼠标位置。 + */ +export default { + mount(container) { + // 长文本列初始宽度固定为 150px,双击应自动测量其内容。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'a', title: 'A', width: 150 }, + { field: 'b', title: 'B', width: 100 }, + { field: 'description', title: 'Description', width: 150 }, + { field: 'name', title: 'Name', width: 150 }], + records: [{ a: 1, b: 2, description: 'A much longer description value', name: 'Item A' }, + { a: 3, b: 4, description: 'Short', name: 'Item B' }], + resize: { colResizeMode: 'all' } + }); + }, + async exercise(page) { + // 先把第三列拖宽,再双击其新的右侧边界。 + const edge = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(2, 0).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + b.x2 - 1, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.move(edge.x, edge.y); + await page.mouse.down(); + await page.mouse.move(edge.x + 120, edge.y, { steps: 10 }); + await page.mouse.up(); + const resized = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(2, 0).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + window.__draggedColumnWidth = b.x2 - b.x1; + return { x: host.x + b.x2 - 1, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.dblclick(resized.x, resized.y, { delay: 80 }); + }, + async verify(page) { + // 拖动必须先增宽,双击后列宽应收缩至文本测量宽度。 + await page.evaluate(() => { + const b = window.__visualTable.getCellRect(2, 0).bounds; + const width = b.x2 - b.x1; + if (window.__draggedColumnWidth < 240 || width >= window.__draggedColumnWidth - 20 || width < 150) + throw new Error(`双击自动列宽未生效:${window.__draggedColumnWidth} -> ${width}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-width-height.mjs b/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-width-height.mjs new file mode 100644 index 0000000000..08c8f1a1cd --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/double-click-auto-width-height.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 6555f4339dabd71aa3845bb9 + * 验证目的:autoHeight 与自动换行同时启用时,双击列边界自动测量列宽并重排行高。 + * 改写:保留来源的列宽、自动换行和双击交互,记录换成匿名长文本。 + */ +export default { + mount(container) { + // 较长描述先换行,使列宽改变同时触发行高更新。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'a', title: 'A', width: 150 }, + { field: 'b', title: 'B', width: 100 }, + { field: 'description', title: 'Description', width: 150 }, + { field: 'name', title: 'Name', width: 150 }], + records: [{ a: 1, b: 2, + description: 'A much longer description value that should initially wrap into several lines', name: 'Item A' }, + { a: 3, b: 4, description: 'Short', name: 'Item B' }], + autoWrapText: true, heightMode: 'autoHeight', resize: { colResizeMode: 'all' } + }); + }, + async exercise(page) { + // 双击第三列表头边界,保留原来源的自动列宽操作。 + const edge = await page.evaluate(() => { + const table = window.__visualTable; + const b = table.getCellRect(2, 0).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + window.__autoWidthBefore = b.x2 - b.x1; + window.__autoRowHeightBefore = table.getCellRect(2, 1).bounds.y2 - table.getCellRect(2, 1).bounds.y1; + return { x: host.x + b.x2 - 1, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.dblclick(edge.x, edge.y, { delay: 80 }); + }, + async verify(page) { + // 文本自动测量后列宽改变,且自动行高未小于最小行高。 + await page.evaluate(() => { + const table = window.__visualTable; + const col = table.getCellRect(2, 0).bounds; + const row = table.getCellRect(2, 1).bounds; + const width = col.x2 - col.x1; + const height = row.y2 - row.y1; + if (width <= window.__autoWidthBefore || height < 24 || height > window.__autoRowHeightBefore + 2) + throw new Error(`双击列宽和自动行高异常:${JSON.stringify({ width, height })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/drag-column-header.mjs b/packages/vtable/__tests__/visual/cases/interaction/drag-column-header.mjs new file mode 100644 index 0000000000..e39eab959f --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/drag-column-header.mjs @@ -0,0 +1,51 @@ +/** + * BugServer case IDs: 646492b0917f340071e15c6e + * 验证目的:分组表头旁的普通列经鼠标拖动后,表头顺序和表体仍一致。 + * 改写:人员与邮箱数据替换为固定通用字段,保留多层表头和 column 拖动模式。 + */ +export default { + mount(container) { + // 来源的 Name 分组位于两个普通列之间,拖动末列可检验整个列结构更新。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 140 }, + { title: 'Name', columns: [ + { field: 'first', title: 'First', width: 130 }, + { field: 'last', title: 'Last', width: 130 } + ] }, + { field: 'contact', title: 'Contact', width: 160 }, + { field: 'date', title: 'Date', width: 120 } + ], + records: Array.from({ length: 8 }, (_, index) => ({ progress: index * 13, + first: `First ${index + 1}`, last: `Last ${index + 1}`, + contact: `contact-${index + 1}`, date: `2024-01-${String(index + 1).padStart(2, '0')}` })), + dragHeaderMode: 'column', widthMode: 'standard' + }); + }, + async exercise(page) { + // 从 Contact 的顶层表头拖到 Progress 左侧,重放来源中的真实列移动。 + const points = await page.evaluate(() => { + const table = window.__visualTable; + const host = document.getElementById('table').getBoundingClientRect(); + return [[3, 0], [0, 0]].map(([col, row]) => { + const bounds = table.getCellRect(col, row).bounds; + return { x: host.x + (bounds.x1 + bounds.x2) / 2, + y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }); + }); + // 来源先点击表头建立可拖动的整列选区。 + await page.mouse.click(points[0].x, points[0].y); + await page.mouse.move(points[0].x, points[0].y); + await page.mouse.down(); + await page.mouse.move(points[1].x, points[1].y, { steps: 12 }); + await page.mouse.up(); + }, + async verify(page) { + // Contact 应移动至 Progress 前面,首行值与字段定义同步。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getHeaderField(0, 0) !== 'contact' || table.getCellValue(0, 2) !== 'contact-1') + throw new Error(`拖动后列顺序错误:${table.columns.map(column => column.field ?? column.title)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/drag-row-series-order.mjs b/packages/vtable/__tests__/visual/cases/interaction/drag-row-series-order.mjs new file mode 100644 index 0000000000..b1a6c08dfe --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/drag-row-series-order.mjs @@ -0,0 +1,54 @@ +/** + * BugServer case IDs: 673728e9013a5500b2fe99ca + * 验证目的:行序号拖动两次后,记录顺序与表体显示同步变化。 + * 改写:将来源的人员和邮箱记录换成固定编号,保留行号拖动与列头拖动配置。 + */ +export default { + mount(container) { + // 足够的匿名记录使两段录制拖动都落在可见表体内。 + container.style.height = '440px'; + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 120 }, { field: 'value', title: 'Value', width: 140 }], + records: Array.from({ length: 8 }, (_, index) => ({ id: index + 1, value: (index + 1) * 10 })), + dragHeaderMode: 'column', + rowSeriesNumber: { enable: true, title: 'No.', field: 'id', dragOrder: true, width: 60, style: { color: 'red' } } + }); + }, + async exercise(page) { + // 从行序号左侧拖动两段不同的行,并分别记录完成后的真实数据顺序。 + const drag = async (fromRow, toRow) => { + const points = await page.evaluate(({ fromRow, toRow }) => { + const table = window.__visualTable; + const host = document.getElementById('table').getBoundingClientRect(); + return [fromRow, toRow].map(row => { + const b = table.getCellRect(0, row).bounds; + return { x: host.x + b.x1 + 18, y: host.y + (b.y1 + b.y2) / 2 }; + }); + }, { fromRow, toRow }); + await page.mouse.move(points[0].x, points[0].y); + await page.mouse.down(); + await page.mouse.move(points[1].x, points[1].y, { steps: 15 }); + await page.mouse.up(); + }; + await drag(2, 5); + await page.evaluate(() => { + const order = window.__visualTable.records.map(record => record.id); + if (order.join(',') === '1,2,3,4,5,6,7,8') throw new Error('首次行序号拖动未重排'); + window.__afterFirstOrder = order.join(','); + }); + await drag(1, 4); + }, + async verify(page) { + // 两次拖动应分别改变顺序,同时保留全部记录且显示顺序跟随数据。 + await page.evaluate(() => { + const table = window.__visualTable; + const order = table.records.map(record => record.id); + if (order.join(',') === window.__afterFirstOrder || + order.slice().sort((a, b) => a - b).join(',') !== '1,2,3,4,5,6,7,8') + throw new Error('第二次拖动结果或记录集合错误'); + for (let row = 1; row <= order.length; row++) { + if (table.getCellOriginValue(1, row) !== order[row - 1]) throw new Error('显示顺序没有同步'); + } + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/drag-row-series-select.mjs b/packages/vtable/__tests__/visual/cases/interaction/drag-row-series-select.mjs new file mode 100644 index 0000000000..ca65d5c884 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/drag-row-series-select.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 67174920f4fe9100b2ce7d33 + * 验证目的:在行序号列纵向拖动可选中连续多行。 + * 改写:原人员记录替换为顺序数字,保留行序号 dragOrder 配置和真实拖动。 + */ +export default { + mount(container) { + // 充足行数使拖动选择能够覆盖多个完整表体行。 + container.style.height = '400px'; + return new window.VTable.ListTable(container, { + columns: [{ field: 'label', title: 'Label', width: 180 }, + { field: 'value', title: 'Value', width: 140 }], + records: Array.from({ length: 8 }, (_, index) => ({ + label: `Item ${index + 1}`, value: index + 1 + })), + rowSeriesNumber: { title: 'No.', dragOrder: true, width: 60 }, + select: { highlightMode: 'row' } + }); + }, + async exercise(page) { + // 从首个行序号拖到第四个行序号,保持鼠标按下跨过两条以上记录。 + const points = await page.evaluate(() => { + const table = window.__visualTable; + const host = document.getElementById('table').getBoundingClientRect(); + return [1, 4].map(row => { + const b = table.getCellRect(0, row).bounds; + return { x: host.x + (b.x1 + b.x2) / 2, + y: host.y + (b.y1 + b.y2) / 2 }; + }); + }); + await page.mouse.move(points[0].x, points[0].y); + await page.mouse.down(); + await page.mouse.move(points[1].x, points[1].y, { steps: 12 }); + await page.mouse.up(); + }, + async verify(page) { + // 拖动后的选择至少跨越两行;像素比较检查序号列高亮。 + await page.evaluate(() => { + const ranges = window.__visualTable.getSelectedCellRanges(); + if (!ranges.some(range => range.end.row - range.start.row >= 2)) + throw new Error(`行序号拖选未覆盖连续行:${JSON.stringify(ranges)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/hover-column.mjs b/packages/vtable/__tests__/visual/cases/interaction/hover-column.mjs new file mode 100644 index 0000000000..a0c2e39d24 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/hover-column.mjs @@ -0,0 +1,61 @@ +/** + * BugServer case IDs: 647ddbcfeafb6dd17a28869b + * 验证目的:录制的表头与表体悬停触发 column 高亮模式。 + * 改写:以单元格矩形代替旧录制坐标,固定公开记录和颜色。 + */ +export default { + mount(container) { + // 固定同一组列和记录,仅切换来源的 hover 模式与表头禁用项。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [ + { progress: 100, id: 1, name: 'A' }, + { progress: 80, id: 2, name: 'B' }, + { progress: 1, id: 3, name: 'C' } + ], + showPin: true, + allowFrozenColCount: 2, + widthMode: 'standard', + hover: { highlightMode: 'column', disableHover: false, disableHeaderHover: true }, + theme: { + bodyStyle: { hover: { cellBgColor: '#CCE0FF', inlineRowBgColor: '#F3F8FF', inlineColumnBgColor: '#F3F8FF' } }, + headerStyle: { hover: { cellBgColor: '#CCE0FF', inlineRowBgColor: '#F3F8FF', inlineColumnBgColor: '#F3F8FF' } } + } + }); + }, + async exercise(page) { + // 先执行录制的表头悬停,再移动到表体,保留最终高亮供截图比较。 + const cellPoint = row => page.evaluate(row => { + const b = window.__visualTable.getCellRect(1, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, row); + await page.mouse.move(850, 60); + const header = await cellPoint(0); + await page.mouse.move(header.x, header.y); + await page.waitForFunction(disabled => { + const hover = window.__visualTable.stateManager.hover; + return hover.disableHeader === disabled && (disabled || hover.cellPos.row === 0); + }, true); + const body = await cellPoint(2); + await page.mouse.move(body.x, body.y); + await page.waitForFunction(() => { + const pos = window.__visualTable.stateManager.hover.cellPos; + return pos.col === 1 && pos.row === 2; + }); + }, + async verify(page) { + // 模式与真实悬停位置必须一致,截图验证着色范围。 + await page.evaluate(expected => { + const table = window.__visualTable; + const hover = table.stateManager.hover; + if (hover.highlightScope !== expected || hover.cellPos.col !== 1 || hover.cellPos.row !== 2) + throw new Error('悬停高亮模式或位置错误'); + if (table.getCellOriginValue(1, 2) !== 2) throw new Error('测试数据缺失'); + }, 'column'); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/hover-cross.mjs b/packages/vtable/__tests__/visual/cases/interaction/hover-cross.mjs new file mode 100644 index 0000000000..7db485162f --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/hover-cross.mjs @@ -0,0 +1,61 @@ +/** + * BugServer case IDs: 6474561b5d221c008666ee1c + * 验证目的:录制的表头与表体悬停触发 cross 高亮模式。 + * 改写:以单元格矩形代替旧录制坐标,固定公开记录和颜色。 + */ +export default { + mount(container) { + // 固定同一组列和记录,仅切换来源的 hover 模式与表头禁用项。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [ + { progress: 100, id: 1, name: 'A' }, + { progress: 80, id: 2, name: 'B' }, + { progress: 1, id: 3, name: 'C' } + ], + showPin: true, + allowFrozenColCount: 2, + widthMode: 'standard', + hover: { highlightMode: 'cross', disableHover: false, disableHeaderHover: true }, + theme: { + bodyStyle: { hover: { cellBgColor: '#CCE0FF', inlineRowBgColor: '#F3F8FF', inlineColumnBgColor: '#F3F8FF' } }, + headerStyle: { hover: { cellBgColor: '#CCE0FF', inlineRowBgColor: '#F3F8FF', inlineColumnBgColor: '#F3F8FF' } } + } + }); + }, + async exercise(page) { + // 先执行录制的表头悬停,再移动到表体,保留最终高亮供截图比较。 + const cellPoint = row => page.evaluate(row => { + const b = window.__visualTable.getCellRect(1, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, row); + await page.mouse.move(850, 60); + const header = await cellPoint(0); + await page.mouse.move(header.x, header.y); + await page.waitForFunction(disabled => { + const hover = window.__visualTable.stateManager.hover; + return hover.disableHeader === disabled && (disabled || hover.cellPos.row === 0); + }, true); + const body = await cellPoint(2); + await page.mouse.move(body.x, body.y); + await page.waitForFunction(() => { + const pos = window.__visualTable.stateManager.hover.cellPos; + return pos.col === 1 && pos.row === 2; + }); + }, + async verify(page) { + // 模式与真实悬停位置必须一致,截图验证着色范围。 + await page.evaluate(expected => { + const table = window.__visualTable; + const hover = table.stateManager.hover; + if (hover.highlightScope !== expected || hover.cellPos.col !== 1 || hover.cellPos.row !== 2) + throw new Error('悬停高亮模式或位置错误'); + if (table.getCellOriginValue(1, 2) !== 2) throw new Error('测试数据缺失'); + }, 'cross'); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/hover-row.mjs b/packages/vtable/__tests__/visual/cases/interaction/hover-row.mjs new file mode 100644 index 0000000000..295024387b --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/hover-row.mjs @@ -0,0 +1,61 @@ +/** + * BugServer case IDs: 647ddbb8733d2ab935925356 + * 验证目的:录制的表头与表体悬停触发 row 高亮模式。 + * 改写:以单元格矩形代替旧录制坐标,固定公开记录和颜色。 + */ +export default { + mount(container) { + // 固定同一组列和记录,仅切换来源的 hover 模式与表头禁用项。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [ + { progress: 100, id: 1, name: 'A' }, + { progress: 80, id: 2, name: 'B' }, + { progress: 1, id: 3, name: 'C' } + ], + showPin: true, + allowFrozenColCount: 2, + widthMode: 'standard', + hover: { highlightMode: 'row', disableHover: false, disableHeaderHover: true }, + theme: { + bodyStyle: { hover: { cellBgColor: '#CCE0FF', inlineRowBgColor: '#F3F8FF', inlineColumnBgColor: '#F3F8FF' } }, + headerStyle: { hover: { cellBgColor: '#CCE0FF', inlineRowBgColor: '#F3F8FF', inlineColumnBgColor: '#F3F8FF' } } + } + }); + }, + async exercise(page) { + // 先执行录制的表头悬停,再移动到表体,保留最终高亮供截图比较。 + const cellPoint = row => page.evaluate(row => { + const b = window.__visualTable.getCellRect(1, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, row); + await page.mouse.move(850, 60); + const header = await cellPoint(0); + await page.mouse.move(header.x, header.y); + await page.waitForFunction(disabled => { + const hover = window.__visualTable.stateManager.hover; + return hover.disableHeader === disabled && (disabled || hover.cellPos.row === 0); + }, true); + const body = await cellPoint(2); + await page.mouse.move(body.x, body.y); + await page.waitForFunction(() => { + const pos = window.__visualTable.stateManager.hover.cellPos; + return pos.col === 1 && pos.row === 2; + }); + }, + async verify(page) { + // 模式与真实悬停位置必须一致,截图验证着色范围。 + await page.evaluate(expected => { + const table = window.__visualTable; + const hover = table.stateManager.hover; + if (hover.highlightScope !== expected || hover.cellPos.col !== 1 || hover.cellPos.row !== 2) + throw new Error('悬停高亮模式或位置错误'); + if (table.getCellOriginValue(1, 2) !== 2) throw new Error('测试数据缺失'); + }, 'row'); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/hover-single.mjs b/packages/vtable/__tests__/visual/cases/interaction/hover-single.mjs new file mode 100644 index 0000000000..b3137d10c3 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/hover-single.mjs @@ -0,0 +1,61 @@ +/** + * BugServer case IDs: 647ddbf6eafb6dd17a28869c + * 验证目的:录制的表头与表体悬停触发 single 高亮模式。 + * 改写:以单元格矩形代替旧录制坐标,固定公开记录和颜色。 + */ +export default { + mount(container) { + // 固定同一组列和记录,仅切换来源的 hover 模式与表头禁用项。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [ + { progress: 100, id: 1, name: 'A' }, + { progress: 80, id: 2, name: 'B' }, + { progress: 1, id: 3, name: 'C' } + ], + showPin: true, + allowFrozenColCount: 2, + widthMode: 'standard', + hover: { highlightMode: 'single', disableHover: false, disableHeaderHover: false }, + theme: { + bodyStyle: { hover: { cellBgColor: '#CCE0FF', inlineRowBgColor: '#F3F8FF', inlineColumnBgColor: '#F3F8FF' } }, + headerStyle: { hover: { cellBgColor: '#CCE0FF', inlineRowBgColor: '#F3F8FF', inlineColumnBgColor: '#F3F8FF' } } + } + }); + }, + async exercise(page) { + // 先执行录制的表头悬停,再移动到表体,保留最终高亮供截图比较。 + const cellPoint = row => page.evaluate(row => { + const b = window.__visualTable.getCellRect(1, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, row); + await page.mouse.move(850, 60); + const header = await cellPoint(0); + await page.mouse.move(header.x, header.y); + await page.waitForFunction(disabled => { + const hover = window.__visualTable.stateManager.hover; + return hover.disableHeader === disabled && (disabled || hover.cellPos.row === 0); + }, false); + const body = await cellPoint(2); + await page.mouse.move(body.x, body.y); + await page.waitForFunction(() => { + const pos = window.__visualTable.stateManager.hover.cellPos; + return pos.col === 1 && pos.row === 2; + }); + }, + async verify(page) { + // 模式与真实悬停位置必须一致,截图验证着色范围。 + await page.evaluate(expected => { + const table = window.__visualTable; + const hover = table.stateManager.hover; + if (hover.highlightScope !== expected || hover.cellPos.col !== 1 || hover.cellPos.row !== 2) + throw new Error('悬停高亮模式或位置错误'); + if (table.getCellOriginValue(1, 2) !== 2) throw new Error('测试数据缺失'); + }, 'single'); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/resize-row-height.mjs b/packages/vtable/__tests__/visual/cases/interaction/resize-row-height.mjs new file mode 100644 index 0000000000..8f7e1c1f0c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/resize-row-height.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 662e1f56d4511700f6885622 + * 验证目的:鼠标拖动列表行边界后行高实际增加,选择状态不阻碍调整。 + * 改写:来源的多列表数据缩为匿名短记录,保留两次动作中的选中后拖动。 + */ +export default { + mount(container) { + // 固定初始行高使拉伸后的变化可以数值断言。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Name', width: 150 }, + { field: 'value', title: 'Value', width: 150 }], + records: Array.from({ length: 5 }, (_, index) => ({ + name: `Item ${index + 1}`, value: index + 1 + })), defaultRowHeight: 32, resize: { rowResizeMode: 'all' } + }); + }, + async exercise(page) { + // 先选中第一数据行,再在行边界向下拖动 32px。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + const b = table.getCellRect(1, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + window.__rowHeightBefore = b.y2 - b.y1; + return { cell: { x: host.x + (b.x1 + b.x2) / 2, + y: host.y + (b.y1 + b.y2) / 2 }, + edge: { x: host.x + (b.x1 + b.x2) / 2, y: host.y + b.y2 + 1 } }; + }); + await page.mouse.click(point.cell.x, point.cell.y); + await page.mouse.move(point.edge.x, point.edge.y); + await page.mouse.down(); + await page.mouse.move(point.edge.x, point.edge.y + 32, { steps: 10 }); + await page.mouse.up(); + }, + async verify(page) { + // 源行的画布矩形必须增高,后续行随之下移。 + await page.evaluate(() => { + const table = window.__visualTable; + const first = table.getCellRect(1, 1).bounds; + const second = table.getCellRect(1, 2).bounds; + if (first.y2 - first.y1 < window.__rowHeightBefore + 15 || second.y1 < first.y2 - 1) + throw new Error(`列表行高未调整:${JSON.stringify({ before: window.__rowHeightBefore, first, second })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-column-disabled.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-column-disabled.mjs new file mode 100644 index 0000000000..c5cf5aa68d --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-column-disabled.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 6544ad1a4280163c75d7fa98 + * 验证目的:列级 disableSelect 与 disableHeaderSelect 分别阻止表体和表头选择。 + * 改写:将录制坐标换成固定单元格矩形,记录改为公开数据。 + */ +export default { + mount(container) { + // 保留来源的选择与列级配置,使用同一组固定数据。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true, disableSelect: true }, + { field: 'id', title: 'ID', width: 100, disableHeaderSelect: true }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }, { progress: 1, id: 3, name: 'C' }], + widthMode: 'standard', + theme: { selectionStyle: { cellBgColor: 'rgba(130, 178, 245, 0.2)', cellBorderLineWidth: 2, cellBorderColor: '#0000ff' } }, + hover: { highlightMode: 'cross', disableHeaderHover: true }, + select: { } + }); + }, + async exercise(page) { + // 重放来源的表体和表头点击,并逐步核对选择结果。 + const click = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await click(0, 1); + await page.waitForTimeout(100); + if (await page.evaluate(() => window.__visualTable.getSelectedCellRanges().length)) + throw new Error('禁选表体列仍被选中'); + await click(1, 0); + await page.waitForTimeout(100); + }, + async verify(page) { + // 最终语义状态与截图中的选择外观应对应。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getSelectedCellRanges().length) throw new Error('禁选表头仍被选中'); + if (table.getCellOriginValue(1, 1) !== 1) throw new Error('测试数据缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-cross.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-cross.mjs new file mode 100644 index 0000000000..8ba660a364 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-cross.mjs @@ -0,0 +1,34 @@ +/** + * BugServer case IDs: 665eb79dfaec2700ad749550 + * 验证目的:cross 选择模式下先点表体、再点表头,各自生成有效选择范围。 + * 改写:将录制坐标替换为单元格矩形定位,并逐步断言两个录制动作。 + */ +export default { + mount(container) { + // 固定表格数据和 cross 模式,供两个顺序点击使用。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 100 }, { field: 'name', title: 'Name', width: 150 }], + records: [{ progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }], + select: { highlightMode: 'cross' }, hover: { highlightMode: 'cross', disableHeaderHover: true } + }); + }, + async exercise(page) { + // 两次操作分别对应 BugServer 录制的表体和表头点击。 + const clickCell = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const bounds = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (bounds.x1 + bounds.x2) / 2, y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await clickCell(1, 1); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 1); + await clickCell(1, 0); + }, + async verify(page) { + // 最终选择必须落在表头;表体动作已在 exercise 中单独验证。 + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 0); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-drag-outside.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-drag-outside.mjs new file mode 100644 index 0000000000..3a61f25bad --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-drag-outside.mjs @@ -0,0 +1,64 @@ +/** + * BugServer case IDs: 64745a465d221c008666ee1f + * 验证目的:普通、拖动、表头和外部点击依次保留预期选择范围及自定义样式。 + * 改写:用单元格矩形执行录制动作,记录改为固定公开数据。 + */ +export default { + mount(container) { + // 保留来源的选择样式、表头排序入口和悬停配置。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [ + { progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }, + { progress: 1, id: 3, name: 'C' }, { progress: 55, id: 4, name: 'D' } + ], + showPin: true, + allowFrozenColCount: 2, + widthMode: 'standard', + theme: { selectionStyle: { cellBgColor: 'rgba(130, 178, 245, 0.2)', cellBorderLineWidth: 2, cellBorderColor: '#0000ff' } }, + hover: { highlightMode: 'cross', disableHover: false, disableHeaderHover: true } + }); + }, + async exercise(page) { + // 按来源顺序执行单选、拖选、表头选择、再次拖选和表外点击。 + const point = (col, row) => page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + const drag = async (start, end) => { + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(end.x, end.y, { steps: 8 }); + await page.mouse.up(); + }; + const first = await point(0, 1); + await page.mouse.click(first.x, first.y); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 1); + await drag(await point(0, 2), await point(2, 3)); + await page.waitForFunction(() => { + const range = window.__visualTable.getSelectedCellRanges()[0]; + return range && range.end.col - range.start.col >= 2 && range.end.row - range.start.row >= 1; + }); + const header = await point(1, 0); + await page.mouse.click(header.x, header.y); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 0); + await drag(await point(0, 1), await point(2, 3)); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.end.col >= 2); + await page.mouse.click(850, 650); + }, + async verify(page) { + // 来源的表外点击不清除已选区域,最终范围和选择样式由截图共同验证。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellOriginValue(0, 1) !== 100) throw new Error('记录数据缺失'); + if (table.theme.selectionStyle?.cellBorderColor !== '#0000ff') throw new Error('选择样式未应用'); + const range = table.getSelectedCellRanges()[0]; + if (!range || range.end.col < 2 || range.end.row < 3) throw new Error('表外点击后选择范围丢失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-global-disabled.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-global-disabled.mjs new file mode 100644 index 0000000000..7350b7efeb --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-global-disabled.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 658d566a20ffa2008c33ebf1 + * 验证目的:全局 disableSelect 阻止表体和表头点击生成选择范围。 + * 改写:将录制坐标换成固定单元格矩形,记录改为公开数据。 + */ +export default { + mount(container) { + // 保留来源的选择与列级配置,使用同一组固定数据。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }, { progress: 1, id: 3, name: 'C' }], + widthMode: 'standard', + theme: { selectionStyle: { cellBgColor: 'rgba(130, 178, 245, 0.2)', cellBorderLineWidth: 2, cellBorderColor: '#0000ff' } }, + hover: { highlightMode: 'cross', disableHeaderHover: true }, + select: { disableSelect: true } + }); + }, + async exercise(page) { + // 重放来源的表体和表头点击,并逐步核对选择结果。 + const click = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await click(2, 1); + await click(2, 0); + await page.waitForTimeout(100); + }, + async verify(page) { + // 最终语义状态与截图中的选择外观应对应。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getSelectedCellRanges().length) throw new Error('全局禁选仍产生选择范围'); + if (!table.options.select?.disableSelect) throw new Error('全局禁选配置未应用'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-header-cell.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-header-cell.mjs new file mode 100644 index 0000000000..a9c11e2598 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-header-cell.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 653102ad8632117ed798a270 + * 验证目的:headerSelectMode 为 cell 时,点击表头只选中该表头格。 + * 改写:将录制坐标换成固定单元格矩形,记录改为公开数据。 + */ +export default { + mount(container) { + // 保留来源的选择与列级配置,使用同一组固定数据。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }, { progress: 1, id: 3, name: 'C' }], + widthMode: 'standard', + theme: { selectionStyle: { cellBgColor: 'rgba(130, 178, 245, 0.2)', cellBorderLineWidth: 2, cellBorderColor: '#0000ff' } }, + hover: { highlightMode: 'cross', disableHeaderHover: true }, + select: { headerSelectMode: 'cell' } + }); + }, + async exercise(page) { + // 重放来源的表体和表头点击,并逐步核对选择结果。 + const click = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await click(2, 0); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.col === 2); + }, + async verify(page) { + // 最终语义状态与截图中的选择外观应对应。 + await page.evaluate(() => { + const table = window.__visualTable; + const range = table.getSelectedCellRanges()[0]; + if (!range || range.start.col !== 2 || range.end.col !== 2 || range.start.row !== 0 || range.end.row !== 0) + throw new Error('表头未保持单格选择'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-header-disabled-drag.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-header-disabled-drag.mjs new file mode 100644 index 0000000000..6cf004b9d7 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-header-disabled-drag.mjs @@ -0,0 +1,52 @@ +/** + * BugServer case IDs: 658d56ae20ffa2008c33ebf4 + * 验证目的:表头禁选时,表体点击仍可选择,列头仍可拖动重排。 + * 改写:使用固定四列和单元格矩形复现点击与拖动顺序。 + */ +export default { + mount(container) { + // 同时启用全列拖动与表头禁选,观察两个交互是否互相影响。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'code', title: 'Code', width: 150 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 100, id: 1, code: 'A1', name: 'A' }, { progress: 80, id: 2, code: 'B2', name: 'B' }], + widthMode: 'standard', + dragHeaderMode: 'all', + select: { disableHeaderSelect: true }, + hover: { highlightMode: 'cross', disableHeaderHover: true } + }); + }, + async exercise(page) { + // 先点击表体与禁选表头,再按来源把 ID 列拖向 Name 列。 + const point = (col, row) => page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + const body = await point(2, 1); + await page.mouse.click(body.x, body.y); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 1); + const codeHeader = await point(2, 0); + await page.mouse.click(codeHeader.x, codeHeader.y); + if (await page.evaluate(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 0)) + throw new Error('禁选表头被选中'); + const start = await point(1, 0); + const end = await point(3, 0); + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(end.x, end.y, { steps: 12 }); + await page.mouse.up(); + }, + async verify(page) { + // ID 列必须离开原位置;表体选择已在拖动前单独检查。 + await page.evaluate(() => { + const table = window.__visualTable; + const fields = Array.from({ length: table.colCount }, (_, col) => table.getHeaderField(col, 0)); + if (fields.indexOf('id') <= 1) throw new Error('禁选表头阻断了列拖动'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-header-scroll-top.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-header-scroll-top.mjs new file mode 100644 index 0000000000..cf9b45ce18 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-header-scroll-top.mjs @@ -0,0 +1,41 @@ +/** + * BugServer case IDs: 6a1416f2709fdc006857f1dc + * 验证目的:大量记录滚动后,冻结表头选择仍位于表头且滚动位置不回跳。 + * 改写:使用匿名序号记录,保留冻结行、inline 表头选择和滚动动作。 + */ +export default { + mount(container) { + // 足够多的记录使 1000px 纵向滚动始终有真实表体可绘制。 + container.style.height = '400px'; + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Name', width: 180 }, + { field: 'value', title: 'Value', width: 140 }, { field: 'group', title: 'Group', width: 140 }], + records: Array.from({ length: 120 }, (_, i) => ({ name: `Item ${i + 1}`, value: i, group: `G${i % 4}` })), + frozenRowCount: 5, heightMode: 'standard', widthMode: 'standard', + select: { headerSelectMode: 'inline', highlightMode: 'column' } + }); + table.selectCell(2, 0); + table.setScrollTop(1000); + return table; + }, + async exercise(page) { + // 在已滚动状态下点击同一列的固定表头。 + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(2, 0).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + window.__scrollBeforeHeaderClick = window.__visualTable.getScrollTop(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 表头仍被选中且滚动位置保持在原区域。 + await page.evaluate(() => { + const table = window.__visualTable; + const range = table.getSelectedCellRanges()[0]; + if (window.__scrollBeforeHeaderClick < 500 || table.getScrollTop() < 500 || + !range || range.start.row !== 0 || range.start.col !== 2) + throw new Error(`滚动后的表头选择异常:${JSON.stringify(range)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-highlight-column.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-column.mjs new file mode 100644 index 0000000000..2ec27ec881 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-column.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 665eb726faec2700ad74954d + * 验证目的:验证 column 选择高亮在表体和表头点击后的状态与截图。 + * 改写:使用固定匿名记录,按单元格矩形重放来源的点击顺序。 + */ +export default { + mount(container) { + // 构建该来源独有的选择配置与通用数据。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 10, id: 1, name: 'A' }, { progress: 20, id: 2, name: 'B' }, { progress: 30, id: 3, name: 'C' }], + select: { highlightMode: 'column' }, + hover: { highlightMode: 'cross', disableHeaderHover: true } + }); + }, + async exercise(page) { + // 重放来源的两次点击,先确认首个动作确实改变了选择,再保留最终状态截图。 + const click = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await click(1, 2); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 2); + await click(1, 0); + }, + async verify(page) { + // 选择范围与高亮模式必须对应最终点击;截图检查主题和扩展着色。 + await page.evaluate(({ expectedScope, expectedRow, headerCell }) => { + const table = window.__visualTable; + const selected = table.getSelectedCellRanges()[0]; + if (table.stateManager.select.highlightScope !== expectedScope || !selected || selected.start.row !== expectedRow) + throw new Error('选择模式或最终位置错误'); + if (headerCell && expectedRow === 0 && (selected.start.col !== 1 || selected.end.col !== 1 || selected.end.row !== 0)) + throw new Error('表头没有保持单格选择'); + }, { expectedScope: 'column', expectedRow: 0, headerCell: false }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-header-cell.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-header-cell.mjs new file mode 100644 index 0000000000..40f7faf06c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-header-cell.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 665eba0ca4737f00b6623d44 + * 验证目的:验证 cross 选择高亮、自定义选择主题、表头单格选择在表体和表头点击后的状态与截图。 + * 改写:使用固定匿名记录,按单元格矩形重放来源的点击顺序。 + */ +export default { + mount(container) { + // 构建该来源独有的选择配置与通用数据。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 10, id: 1, name: 'A' }, { progress: 20, id: 2, name: 'B' }, { progress: 30, id: 3, name: 'C' }], + theme: { selectionStyle: { cellBgColor: 'rgba(130, 178, 245, 0.2)', cellBorderLineWidth: 2, cellBorderColor: '#0000ff', inlineRowBgColor: '#ffe599', inlineColumnBgColor: '#d9e8ff' } }, + select: { highlightMode: 'cross', headerSelectMode: 'cell' }, + hover: { highlightMode: 'cross', disableHeaderHover: true } + }); + }, + async exercise(page) { + // 重放来源的两次点击,先确认首个动作确实改变了选择,再保留最终状态截图。 + const click = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await click(1, 0); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 0); + await click(1, 2); + }, + async verify(page) { + // 选择范围与高亮模式必须对应最终点击;截图检查主题和扩展着色。 + await page.evaluate(({ expectedScope, expectedRow, headerCell }) => { + const table = window.__visualTable; + const selected = table.getSelectedCellRanges()[0]; + if (table.stateManager.select.highlightScope !== expectedScope || !selected || selected.start.row !== expectedRow) + throw new Error('选择模式或最终位置错误'); + if (headerCell && expectedRow === 0 && (selected.start.col !== 1 || selected.end.col !== 1 || selected.end.row !== 0)) + throw new Error('表头没有保持单格选择'); + }, { expectedScope: 'cross', expectedRow: 2, headerCell: true }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-theme.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-theme.mjs new file mode 100644 index 0000000000..3e8028e489 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-theme.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 665eb7fafaec2700ad749551 + * 验证目的:验证 cross 选择高亮、自定义选择主题在表体和表头点击后的状态与截图。 + * 改写:使用固定匿名记录,按单元格矩形重放来源的点击顺序。 + */ +export default { + mount(container) { + // 构建该来源独有的选择配置与通用数据。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 10, id: 1, name: 'A' }, { progress: 20, id: 2, name: 'B' }, { progress: 30, id: 3, name: 'C' }], + theme: { selectionStyle: { cellBgColor: 'rgba(130, 178, 245, 0.2)', cellBorderLineWidth: 2, cellBorderColor: '#0000ff', inlineRowBgColor: '#ffe599', inlineColumnBgColor: '#d9e8ff' } }, + select: { highlightMode: 'cross' }, + hover: { highlightMode: 'cross', disableHeaderHover: true } + }); + }, + async exercise(page) { + // 重放来源的两次点击,先确认首个动作确实改变了选择,再保留最终状态截图。 + const click = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await click(1, 2); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 2); + await click(1, 0); + }, + async verify(page) { + // 选择范围与高亮模式必须对应最终点击;截图检查主题和扩展着色。 + await page.evaluate(({ expectedScope, expectedRow, headerCell }) => { + const table = window.__visualTable; + const selected = table.getSelectedCellRanges()[0]; + if (table.stateManager.select.highlightScope !== expectedScope || !selected || selected.start.row !== expectedRow) + throw new Error('选择模式或最终位置错误'); + if (headerCell && expectedRow === 0 && (selected.start.col !== 1 || selected.end.col !== 1 || selected.end.row !== 0)) + throw new Error('表头没有保持单格选择'); + }, { expectedScope: 'cross', expectedRow: 0, headerCell: false }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-transpose.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-transpose.mjs new file mode 100644 index 0000000000..6e87ec71f7 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-cross-transpose.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 665ecceafaec2700ad749556 + * 验证目的:验证 cross 选择高亮、自定义选择主题、表头单格选择、转置布局在表体和表头点击后的状态与截图。 + * 改写:使用固定匿名记录,按单元格矩形重放来源的点击顺序。 + */ +export default { + mount(container) { + // 构建该来源独有的选择配置与通用数据。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 10, id: 1, name: 'A' }, { progress: 20, id: 2, name: 'B' }, { progress: 30, id: 3, name: 'C' }], + transpose: true, + theme: { selectionStyle: { cellBgColor: 'rgba(130, 178, 245, 0.2)', cellBorderLineWidth: 2, cellBorderColor: '#0000ff', inlineRowBgColor: '#ffe599', inlineColumnBgColor: '#d9e8ff' } }, + select: { highlightMode: 'cross', headerSelectMode: 'cell' }, + hover: { highlightMode: 'cross', disableHeaderHover: true } + }); + }, + async exercise(page) { + // 重放来源的两次点击,先确认首个动作确实改变了选择,再保留最终状态截图。 + const click = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await click(1, 2); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 2); + await click(1, 0); + }, + async verify(page) { + // 选择范围与高亮模式必须对应最终点击;截图检查主题和扩展着色。 + await page.evaluate(({ expectedScope, expectedRow, headerCell }) => { + const table = window.__visualTable; + const selected = table.getSelectedCellRanges()[0]; + if (table.stateManager.select.highlightScope !== expectedScope || !selected || selected.start.row !== expectedRow) + throw new Error('选择模式或最终位置错误'); + if (headerCell && expectedRow === 0 && (selected.start.col !== 1 || selected.end.col !== 1 || selected.end.row !== 0)) + throw new Error('表头没有保持单格选择'); + }, { expectedScope: 'cross', expectedRow: 0, headerCell: true }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-highlight-in-range.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-in-range.mjs new file mode 100644 index 0000000000..4376ac6813 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-in-range.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 672b5b0b71189400b2ec8d56 + * 验证目的:跨行列拖选时 highlightInRange 在范围内着色,表头仍可参与选择。 + * 改写:用匿名三列记录和单元格位置代替固定录制坐标。 + */ +export default { + mount(container) { + // 保留 cross 高亮与表头 inline 选择,使用固定短记录。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'a', title: 'A', width: 130 }, + { field: 'b', title: 'B', width: 130 }, { field: 'c', title: 'C', width: 130 }], + records: [{ a: 1, b: 2, c: 3 }, { a: 4, b: 5, c: 6 }, + { a: 7, b: 8, c: 9 }, { a: 10, b: 11, c: 12 }], + select: { headerSelectMode: 'inline', highlightMode: 'cross', highlightInRange: true } + }); + }, + async exercise(page) { + // 先在表体拖选,再点表头,最终从表头拖到表体留下跨边界范围。 + const point = (col, row) => page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + const drag = async (a, b) => { + await page.mouse.move(a.x, a.y); + await page.mouse.down(); + await page.mouse.move(b.x, b.y, { steps: 8 }); + await page.mouse.up(); + }; + await drag(await point(0, 1), await point(1, 2)); + await page.mouse.click(...Object.values(await point(0, 0))); + await drag(await point(0, 0), await point(2, 3)); + }, + async verify(page) { + // 最终范围必须跨越表头和表体,且高亮范围配置保持启用。 + await page.evaluate(() => { + const table = window.__visualTable; + const range = table.getSelectedCellRanges()[0]; + if (!table.stateManager.select.highlightInRange || !range || + range.start.row !== 0 || range.end.row < 3 || range.end.col < 2) + throw new Error(`跨表头选择范围错误:${JSON.stringify(range)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/interaction/select-highlight-row.mjs b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-row.mjs new file mode 100644 index 0000000000..e08d306c9c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/interaction/select-highlight-row.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 665eb51cfaec2700ad74954a + * 验证目的:验证 row 选择高亮在表体和表头点击后的状态与截图。 + * 改写:使用固定匿名记录,按单元格矩形重放来源的点击顺序。 + */ +export default { + mount(container) { + // 构建该来源独有的选择配置与通用数据。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150 }, + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 10, id: 1, name: 'A' }, { progress: 20, id: 2, name: 'B' }, { progress: 30, id: 3, name: 'C' }], + select: { highlightMode: 'row' }, + hover: { highlightMode: 'cross', disableHeaderHover: true } + }); + }, + async exercise(page) { + // 重放来源的两次点击,先确认首个动作确实改变了选择,再保留最终状态截图。 + const click = async (col, row) => { + const point = await page.evaluate(({ col, row }) => { + const b = window.__visualTable.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }, { col, row }); + await page.mouse.click(point.x, point.y); + }; + await click(1, 2); + await page.waitForFunction(() => window.__visualTable.getSelectedCellRanges()[0]?.start.row === 2); + await click(1, 0); + }, + async verify(page) { + // 选择范围与高亮模式必须对应最终点击;截图检查主题和扩展着色。 + await page.evaluate(({ expectedScope, expectedRow, headerCell }) => { + const table = window.__visualTable; + const selected = table.getSelectedCellRanges()[0]; + if (table.stateManager.select.highlightScope !== expectedScope || !selected || selected.start.row !== expectedRow) + throw new Error('选择模式或最终位置错误'); + if (headerCell && expectedRow === 0 && (selected.start.col !== 1 || selected.end.col !== 1 || selected.end.row !== 0)) + throw new Error('表头没有保持单格选择'); + }, { expectedScope: 'row', expectedRow: 0, headerCell: false }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/keyboard/copy-paste-event.mjs b/packages/vtable/__tests__/visual/cases/keyboard/copy-paste-event.mjs new file mode 100644 index 0000000000..9c255d5e78 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/keyboard/copy-paste-event.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 6a8c0450aa6e45005ce41013 + * 验证目的:列表复制一格后,粘贴事件把文本写入另一行的空单元格。 + * 改写:使用本地事件剪贴板代替宿主 clipboard 权限;保留实际 copy/paste 事件路径。 + */ +export default { + mount(container) { + // 目标格初始为空,不能由现有记录误判为粘贴成功。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Name', width: 120, editor: 'input' }, + { field: 'age', title: 'Age', width: 100, editor: 'input' }, + { field: 'city', title: 'City', width: 140, editor: 'input' }], + records: [{ name: 'Source', age: 'A1', city: 'Source' }, + { name: '', age: '', city: 'Target row' }], + keyboardOptions: { copySelected: true, pasteValueToCell: true }, + editCellTrigger: ['keydown', 'dblclick'] + }); + }, + async exercise(page) { + // 和来源一样先选中源格复制,再选中目标格派发 paste。 + await page.evaluate(() => { + const table = window.__visualTable; + const element = table.getElement(); + const store = {}; + const clipboardData = { setData(type, value) { store[type] = String(value); }, + getData(type) { return store[type] || ''; }, clearData(type) { delete store[type]; } }; + const event = type => { + const value = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(value, 'clipboardData', { value: clipboardData }); + return value; + }; + table.selectCell(0, 1); + element.focus(); + element.dispatchEvent(event('copy')); + window.__listCopiedText = store['text/plain'] || store.text || ''; + table.selectCell(0, 2); + element.dispatchEvent(event('paste')); + }); + }, + async verify(page) { + // 复制文本与目标格都须等于源格,缺动作时断言失败。 + await page.waitForFunction(() => { + const table = window.__visualTable; + return window.__listCopiedText?.trim() === 'Source' && table.getCellValue(0, 2) === 'Source'; + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/keyboard/cut-paste-event.mjs b/packages/vtable/__tests__/visual/cases/keyboard/cut-paste-event.mjs new file mode 100644 index 0000000000..e66e8a71ae --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/keyboard/cut-paste-event.mjs @@ -0,0 +1,59 @@ +/** + * BugServer case IDs: 6a8c0450eb8e12005e80be38 + * 验证目的:剪切两格时事件剪贴板写入原值、清空源格,再粘贴到目标行。 + * 改写:用浏览器内存事件剪贴板代替系统权限,记录只含固定短文本。 + */ +export default { + mount(container) { + // 两列连续源格和同列空目标格构成最小剪切粘贴场景。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Name', width: 140, editor: 'input' }, + { field: 'city', title: 'City', width: 140, editor: 'input' }], + records: [{ name: 'Alpha', city: 'North' }, + { name: 'Beta', city: 'South' }, + { name: '', city: '' }], + keyboardOptions: { copySelected: true, cutSelected: true, pasteValueToCell: true }, + editCellTrigger: ['keydown', 'dblclick'] + }); + }, + async exercise(page) { + // 明确走 ClipboardEvent 回退路径,保留 cut 和 paste 的实际事件处理。 + await page.evaluate(async () => { + const table = window.__visualTable; + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }); + const store = {}; + const clipboardData = { setData(type, value) { store[type] = String(value); return true; }, + getData(type) { return store[type] || ''; }, + clearData(type) { if (type) delete store[type]; else Object.keys(store).forEach(key => delete store[key]); } }; + const event = type => { + const value = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(value, 'clipboardData', { value: clipboardData }); + return value; + }; + table.selectCells([{ start: { col: 0, row: 1 }, end: { col: 1, row: 1 } }]); + window.__cutExpected = table.getCopyValue(); + const input = document.querySelector('#table input.table-focus-control'); + if (input) { input.removeAttribute('readonly'); input.value = 'placeholder'; input.focus(); input.select(); } + table.getElement().focus(); + table.getElement().dispatchEvent(event('cut')); + await new Promise(resolve => setTimeout(resolve, 150)); + window.__cutText = store['text/plain'] || store.text || ''; + table.selectCell(0, 3); + table.getElement().focus(); + table.getElement().dispatchEvent(event('paste')); + await new Promise(resolve => setTimeout(resolve, 150)); + }); + }, + async verify(page) { + // 数据源、复制文本和目标两格都必须反映真实的剪切结果。 + await page.evaluate(() => { + const table = window.__visualTable; + const state = { expected: window.__cutExpected, copied: window.__cutText, + source: [table.getCellValue(0, 1), table.getCellValue(1, 1)], + target: [table.getCellValue(0, 3), table.getCellValue(1, 3)] }; + if (state.copied !== state.expected || state.source.join('|') !== '|' || + state.target.join('|') !== 'Alpha|North') + throw new Error(`剪切粘贴状态异常:${JSON.stringify(state)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/keyboard/disabled-arrow-navigation.mjs b/packages/vtable/__tests__/visual/cases/keyboard/disabled-arrow-navigation.mjs new file mode 100644 index 0000000000..6bad7e5c19 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/keyboard/disabled-arrow-navigation.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 6697b10caeb8a00109f2ddb3, 6697b191aeb8a00109f2ddb4 + * 验证目的:键盘向左跳过禁选列,向上到表头时选中可用的整列。 + * 改写:保留禁选首列与键盘操作,记录改为连续匿名编号。 + */ +export default { + mount(container) { + // 首列禁止选择,其余两列允许键盘导航。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 120, disableSelect: true }, + { field: 'name', title: 'Name', width: 160 }, + { field: 'score', title: 'Score', width: 120 }], + records: Array.from({ length: 4 }, (_, i) => ({ id: i + 1, + name: `Item ${i + 1}`, score: (i + 1) * 10 })) + }); + table.getElement().tabIndex = 0; + return table; + }, + async exercise(page) { + // 两个方向分别从源用例相同的单元格起点发出原生键盘事件。 + await page.evaluate(() => { + const table = window.__visualTable; + table.selectCell(1, 2); + table.getElement().focus(); + }); + await page.keyboard.press('Control+ArrowLeft'); + await page.evaluate(() => { + window.__leftRanges = window.__visualTable.getSelectedCellRanges(); + window.__visualTable.selectCell(1, 1); + window.__visualTable.getElement().focus(); + }); + await page.keyboard.press('ArrowUp'); + }, + async verify(page) { + // 向左保持在可选列,向上进入表头后只能选择当前整列。 + await page.evaluate(() => { + const left = window.__leftRanges; + const up = window.__visualTable.getSelectedCellRanges(); + if (!left.length || left.some(range => range.start.col === 0 || range.end.col === 0) || + up.length !== 1 || up[0].start.col !== 1 || up[0].end.col !== 1 || + up[0].start.row !== 0 || up[0].end.row !== 4) + throw new Error(`禁选导航结果异常:${JSON.stringify({ left, up })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/keyboard/select-all-body.mjs b/packages/vtable/__tests__/visual/cases/keyboard/select-all-body.mjs new file mode 100644 index 0000000000..06cbdb1cdc --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/keyboard/select-all-body.mjs @@ -0,0 +1,49 @@ +/** + * BugServer case IDs: 665ed085faec2700ad74955b + * 验证目的:Ctrl/Command+A 选中表体时,disableHeaderSelect 排除表头。 + * 改写:来源虽无 interactions 字段,但源码会自行派发 keydown;保留该事件路径并移除远程编辑器加载。 + */ +export default { + mount(container) { + // 固定三列与三行,便于检查全选范围的上下边界。 + container.style.width = '800px'; + container.style.height = '400px'; + return new window.VTable.ListTable(container, { + columns: [ + { field: 'name', title: 'Name', width: 150 }, + { field: 'date', title: 'Date', width: 150 }, + { field: 'id', title: 'ID', width: 100 } + ], + records: [ + { name: 'A', date: '2019-01-01', id: 1 }, + { name: 'B', date: '2020-02-02', id: 2 }, + { name: 'C', date: '2021-03-03', id: 3 } + ], + keyboardOptions: { copySelected: true, pasteValueToCell: true, + selectAllOnCtrlA: { disableHeaderSelect: true } } + }); + }, + async exercise(page) { + // 来源先选局部区域,再向表格根元素派发组合键事件。 + await page.evaluate(() => { + const table = window.__visualTable; + table.selectCells([{ start: { col: 0, row: 2 }, end: { col: 2, row: 3 } }]); + table.getElement().tabIndex = 0; + table.getElement().focus(); + const event = document.createEvent('Event'); + event.initEvent('keydown', true, false); + Object.assign(event, { ctrlKey: true, metaKey: true, altKey: true, + which: 67, keyCode: 67, key: 'a', code: 'KeyA' }); + table.getElement().dispatchEvent(event); + }); + }, + async verify(page) { + // 全选覆盖表体首末行,且任一范围都不含第零行表头。 + await page.evaluate(() => { + const table = window.__visualTable; + const ranges = table.getSelectedCellRanges(); + if (ranges.length !== 1 || ranges[0].start.row !== 1 || ranges[0].end.row !== table.rowCount - 1) + throw new Error(`Ctrl/Command+A 未选中完整表体:${JSON.stringify(ranges)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/language/cyrillic-wrap.mjs b/packages/vtable/__tests__/visual/cases/language/cyrillic-wrap.mjs new file mode 100644 index 0000000000..c91db0fa05 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/language/cyrillic-wrap.mjs @@ -0,0 +1,25 @@ +/** + * BugServer case IDs: 65e69276603de700d1d8b9e5 + * 验证目的:自定义西里尔字母集后多语言文本仍正常换行。 + * 改写:保留西里尔混排结构,文本换成通用示例。 + */ +export default { + mount(container) { + // 测试页逐例隔离,修改全局字母集不会影响其他用例。 + window.VTable.setCustomAlphabetCharSet('БВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯбвгдежзийклмнопрстуфхцчшщъыьэюя'); + return new window.VTable.ListTable(container, { + columns: [{ field: 'latin', title: 'Column 1', width: 'auto' }, + { field: 'cyrillic', title: 'Колонка 2', width: 'auto' }, + { field: 'mixed', title: 'Mixed колонка', width: 100 }], + records: [{ latin: 'Example text with correct wrapping', cyrillic: 'Пример текста на кириллице', + mixed: 'GGGGGGGGGGGGGGGG ЮЮЮЮЮЮЮЮЮЮЮЮЮЮЮЮ' }], + widthMode: 'standard', heightMode: 'autoHeight', autoWrapText: true + }); + }, + async verify(page) { + // 混合字符内容必须进入表格。 + await page.evaluate(() => { + if (!window.__visualTable.getCellValue(2, 1).includes('Ю')) throw new Error('西里尔文本缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/adaptive-width.mjs b/packages/vtable/__tests__/visual/cases/layout/adaptive-width.mjs new file mode 100644 index 0000000000..161fa897bd --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/adaptive-width.mjs @@ -0,0 +1,24 @@ +/** + * BugServer case IDs: 64bdffe85134109a76ccddd9 + * 验证目的:adaptive 模式将多列分配到容器宽度。 + * 改写:使用通用字段,保留定宽列与 adaptive 模式。 + */ +export default { + mount(container) { + // 设定小于容器宽度的列宽,让 adaptive 分配剩余空间。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, fieldFormat: r => `${r.progress}%` }, + { field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'name', title: 'Name', width: 150 }], + records: [{ progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }], + widthMode: 'adaptive', allowFrozenColCount: 2 + }); + }, + async verify(page) { + // 最后一列表头应扩展到接近容器右边界。 + await page.evaluate(() => { + const b = window.__visualTable.getCellRect(2, 0).bounds; + if (b.x2 < 700) throw new Error(`adaptive 宽度不足:${b.x2}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/auto-fill-width-resize-container.mjs b/packages/vtable/__tests__/visual/cases/layout/auto-fill-width-resize-container.mjs new file mode 100644 index 0000000000..e802a57dcc --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/auto-fill-width-resize-container.mjs @@ -0,0 +1,51 @@ +/** + * BugServer case IDs: 6532320388cd1012fefa741c, 6540b514f95bdcf7c340d701 + * 验证目的:autoFillWidth 下拖动列边界,再改变容器宽度,列宽与表格仍同步更新。 + * 改写:合并同配置的两条来源,保留内部初始调整、真实鼠标拖动和容器变化。 + */ +export default { + mount(container) { + // 固定两列和容器宽度,先按来源通过状态管理器调整首列。 + container.style.width = '800px'; + container.style.height = '400px'; + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', showSort: true }, + { field: 'id', title: 'ID', width: 100 }], + records: [{ progress: 100, id: 1 }, { progress: 80, id: 2 }, + { progress: 60, id: 3 }, { progress: 40, id: 4 }], + autoFillWidth: true, widthMode: 'standard', allowFrozenColCount: 2, + resize: { colResizeMode: 'all' } + }); + table.stateManager.startResizeCol(0, 700, 120); + table.stateManager.updateResizeCol(670, 120); + table.stateManager.endResizeCol(); + return table; + }, + async exercise(page) { + // 从首列表头右边界向左拖动,再将宿主增宽 1px 触发重新布局。 + const edge = await page.evaluate(() => { + const table = window.__visualTable; + const b = table.getCellRect(0, 0).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + window.__widthBeforeDrag = b.x2 - b.x1; + return { x: host.x + b.x2 - 1, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.move(edge.x, edge.y); + await page.mouse.down(); + await page.mouse.move(edge.x - 100, edge.y, { steps: 10 }); + await page.mouse.up(); + await page.evaluate(() => { document.getElementById('table').style.width = '801px'; }); + await page.waitForTimeout(100); + }, + async verify(page) { + // 最终首列必须比拖动前窄,容器变化后其余列仍有有效宽度。 + await page.evaluate(() => { + const table = window.__visualTable; + const first = table.getCellRect(0, 0).bounds; + const last = table.getCellRect(1, 0).bounds; + if (first.x2 - first.x1 >= window.__widthBeforeDrag - 30 || last.x2 - last.x1 < 80 || + document.getElementById('table').clientWidth !== 801) + throw new Error(`容器变化后列宽异常:${JSON.stringify({ before: window.__widthBeforeDrag, first, last })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/auto-width-height.mjs b/packages/vtable/__tests__/visual/cases/layout/auto-width-height.mjs new file mode 100644 index 0000000000..37d0ad20a0 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/auto-width-height.mjs @@ -0,0 +1,32 @@ +/** + * BugServer case IDs: 64bdfed95134109a76ccddd6, 64be004b5134109a76ccddda + * 验证目的:autoWidth 列宽与 autoHeight 行高在同一列表上生效。 + * 改写:合并共享记录与列结构的两个来源,保留两种自适应模式。 + */ +export default { + mount(container) { + // 长字段可触发自动测量,固定输入保证双侧结果可比较。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'name', title: 'Name', width: 'auto' }, + { field: 'description', title: 'Description', width: 'auto', style: { autoWrapText: true } }, + { field: 'value', title: 'Value', width: 'auto' } + ], + records: [ + { name: 'A', description: 'A long description that wraps inside a measured column.', value: 10 }, + { name: 'B', description: 'Another fixed description.', value: 20 } + ], + widthMode: 'autoWidth', heightMode: 'autoHeight', autoWrapText: true, + allowFrozenColCount: 2 + }); + }, + async verify(page) { + // 检查测量后的尺寸是有限的正值,而非仅检查配置。 + await page.evaluate(() => { + const table = window.__visualTable; + if (!(table.getColWidth(1) > 0) || !(table.getRowHeight(1) > 0)) + throw new Error('自适应尺寸无效'); + if (table.getCellValue(0, 2) !== 'B') throw new Error('自适应表格数据缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/canvas-size-options.mjs b/packages/vtable/__tests__/visual/cases/layout/canvas-size-options.mjs new file mode 100644 index 0000000000..4084311f1c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/canvas-size-options.mjs @@ -0,0 +1,63 @@ +/** + * BugServer case IDs: 678620838e768100b114c398, 6786222198804d00b1d62177, 67862349af3eab00b234917d, 678624597020de00b01c0b49 + * 验证目的:固定、自动、最大限制及 updateOption 后的 canvas 尺寸分别生效。 + * 改写:来源的多组重复人员列缩为三列八行匿名数值。 + */ +export default { + mount(container) { + // 四张列表共用输入,分别展示固定尺寸、内容自动撑开、上限和配置更新。 + container.style.width = '800px'; + container.style.height = '900px'; + container.style.display = 'grid'; + container.style.gridTemplateColumns = '1fr 1fr'; + const options = { + columns: [0, 1, 2].map(index => ({ field: `c${index}`, title: `Column ${index}`, width: 180 })), + records: Array.from({ length: 8 }, (_, row) => ({ c0: row, c1: row + 10, c2: row + 20 })), + defaultRowHeight: 42, widthMode: 'standard' + }; + const variants = [ + { canvasWidth: 300, canvasHeight: 200 }, + { canvasWidth: 'auto', canvasHeight: 'auto' }, + { canvasWidth: 'auto', canvasHeight: 'auto', maxCanvasWidth: 500, maxCanvasHeight: 300 }, + { canvasWidth: 200, canvasHeight: 160 } + ]; + const tables = variants.map(variant => { + const host = document.createElement('div'); + host.style.cssText = 'width:390px;height:440px;overflow:hidden'; + container.append(host); + return new window.VTable.ListTable(host, { ...options, ...variant }); + }); + return { + tables, + updateTarget() { + // 模拟来源中先用固定尺寸构造,再切到内容自适应的配置更新。 + return tables[3].updateOption({ ...options, canvasWidth: 'auto', canvasHeight: 'auto' }); + }, + async renderAsync() { + // 等待所有画布的首次绘制。 + await Promise.all(tables.map(table => table.renderAsync?.())); + }, + release() { + // 每张表在快照结束后独立释放。 + tables.forEach(table => table.release()); + } + }; + }, + async exercise(page) { + // 自动尺寸在内部异步计算,待其完成后切换第四张表。 + await page.waitForFunction(() => window.__visualTable.tables[1].canvasWidth > 500); + await page.evaluate(() => window.__visualTable.updateTarget()); + await page.waitForFunction(() => window.__visualTable.tables[3].canvasWidth > 500); + }, + async verify(page) { + // 四种配置的实际宽高须符合固定值、自动增长及最大值边界。 + await page.evaluate(() => { + const sizes = window.__visualTable.tables.map(table => [table.canvasWidth, table.canvasHeight]); + if (Math.abs(sizes[0][0] - 300) > 2 || Math.abs(sizes[0][1] - 200) > 2 || + sizes[1][0] <= 500 || sizes[1][1] <= 300 || + sizes[2][0] > 501 || sizes[2][1] > 301 || + sizes[3][0] <= 500 || sizes[3][1] <= 300) + throw new Error(`画布尺寸选项未生效:${JSON.stringify(sizes)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/container-fit-both.mjs b/packages/vtable/__tests__/visual/cases/layout/container-fit-both.mjs new file mode 100644 index 0000000000..d78879ae2a --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/container-fit-both.mjs @@ -0,0 +1,27 @@ +/** + * BugServer case IDs: 684bf27f12d99200a8e2dd42 + * 验证目的:autoWidth 与 containerFit=true 同时启用时宽高都适配固定容器。 + * 改写:保留布尔配置和固定宿主尺寸,缩减重复展示字段。 + */ +export default { + mount(container) { + // 固定容器后使用布尔适配,区分已有的仅适配高度场景。 + container.style.width = '800px'; + container.style.height = '400px'; + return new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'name', title: 'Name', width: 150 }], + records: [100, 80, 1, 55, 28].map((progress, i) => ({ progress, id: i + 1, name: `Name ${i + 1}` })), + widthMode: 'autoWidth', containerFit: true, showPin: true, allowFrozenColCount: 2 + }); + }, + async verify(page) { + // 适配后末条记录和表格宽高都必须有效;具体布局交给截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 5) !== 28 || table.tableNoFrameWidth <= 0 || table.tableNoFrameHeight <= 0) + throw new Error('布尔 containerFit 适配失败'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/container-fit-height.mjs b/packages/vtable/__tests__/visual/cases/layout/container-fit-height.mjs new file mode 100644 index 0000000000..37d64e2f9b --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/container-fit-height.mjs @@ -0,0 +1,26 @@ +/** + * BugServer case IDs: 684bf2cd228c0a00a7015501 + * 验证目的:autoWidth 与 containerFit.height 同时启用时,列表适配容器。 + * 改写:缩减展示字段,保留高度单独适配的对象形式。 + */ +export default { + mount(container) { + // 明确固定宿主尺寸,触发与普通 autoWidth 不同的高度适配。 + container.style.width = '800px'; + container.style.height = '400px'; + return new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100, sort: true }, { field: 'name', title: 'Name', width: 150 }], + records: [100, 80, 1, 55, 28].map((progress, i) => ({ progress, id: i + 1, name: `Name ${i + 1}` })), + widthMode: 'autoWidth', containerFit: { height: true }, allowFrozenColCount: 2 + }); + }, + async verify(page) { + // 记录和配置必须有效,容器适配外观由截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 5) !== 28 || table.rowCount !== 6) + throw new Error('高度适配场景未完成绘制'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/custom-compute-row-height.mjs b/packages/vtable/__tests__/visual/cases/layout/custom-compute-row-height.mjs new file mode 100644 index 0000000000..baae1f6a4a --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/custom-compute-row-height.mjs @@ -0,0 +1,28 @@ +/** + * BugServer case IDs: 673c5cad013a5500b2fe99e1 + * 验证目的:customComputeRowHeight 能按记录将指定数据行设为独立高度。 + * 改写:去除来源的邮箱和 SVG,仅保留一列匿名记录及高度回调。 + */ +export default { + mount(container) { + // 第二条记录使用 160px,其他数据行使用 40px。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'item', title: 'Item', width: 220 }], + records: [{ item: 'First' }, { item: 'Tall' }, { item: 'Third' }], + defaultRowHeight: 'auto', + customComputeRowHeight({ row, table }) { + // 回调按当前可见行对应的记录返回固定高度。 + return table.getRecordByCell(0, row)?.item === 'Tall' ? 160 : 40; + } + }); + }, + async verify(page) { + // 第二条记录高于前后记录,且场景图矩形反映相同高度。 + await page.evaluate(() => { + const table = window.__visualTable; + const heights = [1, 2, 3].map(row => table.getRowHeight(row)); + if (heights[1] < 150 || heights[0] > 50 || heights[2] > 50) + throw new Error(`自定义行高错误:${JSON.stringify(heights)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/key-size-config.mjs b/packages/vtable/__tests__/visual/cases/layout/key-size-config.mjs new file mode 100644 index 0000000000..62311f51e5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/key-size-config.mjs @@ -0,0 +1,28 @@ +/** + * BugServer case IDs: 6909e268711d9200a827cd92, 6909e3c4711d9200a827cd93 + * 验证目的:稳定 key 的列宽、行高配置同时覆盖百分比宽度和自动填高。 + * 改写:两条来源共用后者的配置超集,个人字段换成通用名称。 + */ +export default { + mount(container) { + // key 0/1/2 与列宽、行高配置逐一对应。 + return new window.VTable.ListTable(container, { + columns: [{ key: 0, field: 'progress', title: 'Progress', width: 'calc(20% - 20px)' }, + { title: 'Name', columns: [{ key: 1, field: 'first', title: 'First', width: '20%' }, + { key: 2, field: 'last', title: 'Last', width: '20%' }] }], + records: [{ progress: 20, first: 'A', last: 'One' }, { progress: 80, first: 'B', last: 'Two' }], + dragHeaderMode: 'column', autoFillHeight: true, + columnWidthConfig: [{ key: 0, width: 150 }, { key: 1, width: 150 }, { key: 2, width: 150 }], + rowHeightConfig: [{ key: 0, height: 150 }, { key: 1, height: 150 }, { key: 2, height: 150 }] + }); + }, + async verify(page) { + // 第一列宽度和首条记录高度应使用 key 配置。 + await page.evaluate(() => { + const table = window.__visualTable; + const b = table.getCellRect(0, 1).bounds; + if (Math.abs((b.x2 - b.x1) - 150) > 2 || b.y2 - b.y1 < 100) + throw new Error(`key 尺寸配置未生效:${b.x2 - b.x1} × ${b.y2 - b.y1}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/limit-max-auto-width.mjs b/packages/vtable/__tests__/visual/cases/layout/limit-max-auto-width.mjs new file mode 100644 index 0000000000..46bf1e3738 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/limit-max-auto-width.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 64b9f06824402a7e07da8e3c, 6559c7c838a83fc5d23015a4 + * 验证目的:autoWidth 的最大宽度限制在 false 与 100 配置下确实不同。 + * 改写:来源的格式化个人记录改为单个固定长文本单元格。 + */ +export default { + mount(container) { + // 两张列表只有最大自动列宽限制不同,便于直接比较实测列宽。 + container.style.width = '800px'; + container.style.height = '310px'; + container.style.display = 'grid'; + container.style.gridTemplateColumns = '1fr 1fr'; + const tables = [false, 100].map(limitMaxAutoWidth => { + const host = document.createElement('div'); + host.style.cssText = 'width:390px;height:300px'; + container.append(host); + return new window.VTable.ListTable(host, { + columns: [{ field: 'text', title: 'Description', width: 'auto' }], + records: [{ text: 'A long anonymous description that requires a much wider automatically sized column' }], + widthMode: 'autoWidth', limitMaxAutoWidth + }); + }); + return { + tables, + async renderAsync() { + // 等待自动列宽计算和绘制完成。 + await Promise.all(tables.map(table => table.renderAsync?.())); + }, + release() { + // 比较完成后释放两张列表。 + tables.forEach(table => table.release()); + } + }; + }, + async verify(page) { + // 关闭限制时的实测宽度应明显超过 100,另一张应被限制。 + await page.evaluate(() => { + const [unlimited, capped] = window.__visualTable.tables; + const widths = [unlimited.getColWidth(0), capped.getColWidth(0)]; + if (widths[0] <= widths[1] + 80 || widths[1] > 101) + throw new Error(`自动列宽上限未区分:${JSON.stringify(widths)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/layout/min-max-adaptive-width.mjs b/packages/vtable/__tests__/visual/cases/layout/min-max-adaptive-width.mjs new file mode 100644 index 0000000000..581842ac51 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/layout/min-max-adaptive-width.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 66f28be2885776011c598a30 + * 验证目的:自适应列宽同时遵守各列的 minWidth 和 maxWidth。 + * 改写:使用短文本和长文本的固定记录,去除来源的大型样式及数据。 + */ +export default { + mount(container) { + // 三列均允许自动测量,但宽度应在 100px 至 150px 之间。 + container.style.width = '430px'; + container.style.height = '300px'; + return new window.VTable.ListTable(container, { + columns: [ + { field: 'short', title: 'Short', width: 'auto', minWidth: 100, maxWidth: 150 }, + { field: 'long', title: 'Long label', width: 'auto', minWidth: 100, maxWidth: 150 }, + { field: 'mixed', title: 'Mixed', width: 'auto', minWidth: 100, maxWidth: 150 } + ], + records: [ + { short: 'A', long: 'A deliberately long text value for auto width', mixed: 'B' }, + { short: 'C', long: 'Another long text value', mixed: 'D' } + ], + widthMode: 'adaptive', heightMode: 'autoHeight', autoWrapText: true + }); + }, + async verify(page) { + // 实际计算后的三列宽度必须同时满足上下限。 + await page.evaluate(() => { + const table = window.__visualTable; + const widths = [0, 1, 2].map(col => table.getColWidth(col)); + if (widths.some(width => width < 100 || width > 150)) + throw new Error(`自适应列宽越界:${JSON.stringify(widths)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/menu/disabled-item.mjs b/packages/vtable/__tests__/visual/cases/menu/disabled-item.mjs new file mode 100644 index 0000000000..bdac43c51f --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/menu/disabled-item.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 67fcb22b3376ca00a6024ff8 + * 验证目的:表头下拉菜单的子项可显示禁用状态。 + * 改写:缩短无关 SVG 和展示字段,保留 HTML 菜单、嵌套项及悬停点击顺序。 + */ +export default { + mount(container) { + // 长标题用于覆盖菜单避让,子菜单第二项禁用。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'value', title: 'Value', width: 150 }, { field: 'name', title: 'Name', width: 150 }], + records: [{ value: 10, name: 'A' }], + menu: { renderMode: 'html', defaultHeaderMenuItems: [{ text: 'Sort options with long title', children: [ + { text: 'Ascending', menuKey: 'asc' }, { text: 'Descending', menuKey: 'desc', disabled: true } + ] }] } + }); + }, + async exercise(page) { + // 鼠标到首列表头右边缘,点击菜单图标,再悬停父菜单项。 + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(0, 0).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + b.x2 - 12, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.move(point.x, point.y); + await page.mouse.click(point.x, point.y); + await page.locator('.vtable__menu-element__item').filter({ hasText: 'Sort options with long title' }).first().hover(); + }, + async verify(page) { + // 子菜单必须出现,Descending 带禁用样式。 + await page.waitForFunction(() => [...document.querySelectorAll('.vtable__menu-element__item-disabled')] + .some(item => item.textContent?.includes('Descending'))); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/menu/dropdown-highlight.mjs b/packages/vtable/__tests__/visual/cases/menu/dropdown-highlight.mjs new file mode 100644 index 0000000000..718847a559 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/menu/dropdown-highlight.mjs @@ -0,0 +1,32 @@ +/** + * BugServer case IDs: 65d4959b97cc3d008de5b594 + * 验证目的:列下拉菜单设置高亮项后,同时绘制菜单状态图标。 + * 改写:个人信息和长 SVG 改为通用数据与本地小图标,保留三个图标属性及 ROW_DESC 菜单键。 + */ +export default { + mount(container) { + // 菜单项的普通、选中、状态图标分别配置,再标记降序项高亮。 + const triangle = ''; + const selected = ''; + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 80 }, + { field: 'code', title: 'Code', width: 180, dropDownMenu: [ + { text: 'Descending', menuKey: 'ROW_DESC', icon: { svg: triangle }, + selectedIcon: { svg: selected }, stateIcon: { svg: selected } }, + { text: 'Ascending', menuKey: 'ROW_ASC', icon: { svg: triangle } } + ] }], + records: [{ id: 1, code: 'A' }, { id: 2, code: 'B' }, { id: 3, code: 'C' }] + }); + table.setDropDownMenuHighlight([{ row: 0, col: 1, menuKey: 'ROW_DESC' }]); + return table; + }, + async verify(page) { + // 菜单高亮状态和表体记录必须同时保留,状态图标交由截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + const state = table.stateManager.menu.dropDownMenuHighlight; + if (state?.[0]?.menuKey !== 'ROW_DESC' || state[0].col !== 1 || table.getCellValue(1, 1) !== 'A') + throw new Error('下拉菜单高亮状态未建立'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/area-axis-sort.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/area-axis-sort.mjs new file mode 100644 index 0000000000..55885dd583 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/area-axis-sort.mjs @@ -0,0 +1,38 @@ +/** + * BugServer case IDs: 6541cdd044dadd6b7cb441cd + * 验证目的:PivotChart 面积图启用 sortDataByAxis 并绘制两组输入点。 + * 改写:把来源的大型透视数据缩成匿名的三期两组记录。 + */ +export default { + mount(container) { + // sortDataByAxis 保留来源的面积图轴排序配置。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + return new window.VTable.PivotChart(container, { + rows: ['group'], columns: ['bucket'], indicatorsAsCol: false, + indicators: [{ indicatorKey: 'value', title: 'Area', cellType: 'chart', chartModule: 'vchart', + chartSpec: { type: 'area', data: { id: 'data' }, xField: 'period', yField: 'value', + seriesField: 'series', sortDataByAxis: true, animation: false } }], + records: [ + { group: 'G', bucket: 'One', period: 'P3', series: 'A', value: 7 }, + { group: 'G', bucket: 'One', period: 'P1', series: 'A', value: 3 }, + { group: 'G', bucket: 'One', period: 'P2', series: 'A', value: 5 }, + { group: 'G', bucket: 'One', period: 'P2', series: 'B', value: 4 }, + { group: 'G', bucket: 'One', period: 'P3', series: 'B', value: 6 }, + { group: 'G', bucket: 'One', period: 'P1', series: 'B', value: 2 } + ], + defaultRowHeight: 270, defaultHeaderRowHeight: 40, + defaultColWidth: 550, defaultHeaderColWidth: 80, widthMode: 'standard' + }); + }, + async verify(page) { + // 确认实际建立面积图单元格,具体区域形状由视觉快照比较。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 1) throw new Error('面积图单元格缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/bar-line-right-axis.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/bar-line-right-axis.mjs new file mode 100644 index 0000000000..1b56eaa280 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/bar-line-right-axis.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 65a8a7d436599cd39121a863, 67287bffe4bb4400b299cba7, 692904557eb69c005d1e0a18 + * 验证目的:百分比柱宽与按 seriesId 分配的左右轴可在柱线透视图中绘制。 + * 改写:两个指标分成固定数据流,保留柱宽字符串、seriesId 数组和右轴。 + */ +export default { + mount(container) { + // 每个指标拥有独立数据流,避免一侧轴吞掉另一侧的图元。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + return new window.VTable.PivotChart(container, { + rows: [], columns: [], indicatorsAsCol: false, + records: [ + { period: 'A', amount: 10 }, { period: 'B', amount: 18 }, + { period: 'C', amount: 13 }, { period: 'A', rate: 0.2 }, + { period: 'B', rate: 0.6 }, { period: 'C', rate: 0.4 } + ], + indicators: [ + { indicatorKey: 'amount', title: 'Amount', cellType: 'chart', chartModule: 'vchart', + chartSpec: { type: 'common', + series: [{ id: 'amount', type: 'bar', data: { id: 'amount' }, + xField: 'period', yField: 'amount', barWidth: '100%' }], + axes: [{ orient: 'left', seriesId: ['amount'], min: 0 }] } }, + { indicatorKey: 'rate', title: 'Rate', cellType: 'chart', chartModule: 'vchart', + chartSpec: { type: 'common', + series: [{ id: 'rate', type: 'line', data: { id: 'rate' }, + xField: 'period', yField: 'rate', point: { visible: true } }], + axes: [{ orient: 'right', seriesId: ['rate'], min: 0, max: 1 }] } } + ], + defaultRowHeight: 180, defaultColWidth: 560, + widthMode: 'autoWidth', heightMode: 'autoHeight' + }); + }, + async verify(page) { + // 两种图元都要占据可见图表单元格,避免只比较空轴。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + if (charts !== 2) throw new Error(`双轴图表单元格数量错误:${charts}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/box-plot-horizontal.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/box-plot-horizontal.mjs new file mode 100644 index 0000000000..13580d8e91 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/box-plot-horizontal.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 690325fb1ef44500a8bec374 + * 验证目的:箱线图水平布局在两级列维度中绘制有无离群点的数据。 + * 改写:缩减重复分组记录,保留六个分位数字段、水平轴和两级列维度。 + */ +export default { + mount(container) { + // 图表模块从本地 bundle 注册,箱线图方向为 horizontal。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + return new window.VTable.PivotChart(container, { + columns: [{ dimensionKey: 'country', title: 'Country' }, { dimensionKey: 'region', title: 'Region' }], + rows: [{ dimensionKey: 'category', title: 'Category' }], + indicators: [{ indicatorKey: 'max', title: 'Distribution', cellType: 'chart', chartModule: 'vchart', + style: { padding: 1 }, chartSpec: { type: 'boxPlot', data: { id: 'dataId' }, + yField: 'item', minField: 'min', q1Field: 'q1', medianField: 'median', + q3Field: 'q3', maxField: 'max', outliersField: 'outliers', direction: 'horizontal', + boxPlot: { style: { shaftShape: 'line', lineWidth: 2 } } } }], + records: [{ country: 'A', region: 'South', category: 'Device', item: 'Type 1', + min: 4.72, q1: 9.73, median: 10.17, q3: 10.51, max: 11.64, outliers: [12.01, 12.02, 14.03] }, + { country: 'A', region: 'North', category: 'Device', item: 'Type 1', + min: 9.4, q1: 10.06, median: 10.75, q3: 11.56, max: 12.5 }, + { country: 'B', region: 'East', category: 'Device', item: 'Type 2', + min: 8.74, q1: 9.46, median: 10.35, q3: 10.94, max: 12.21 }], + indicatorsAsCol: true, defaultRowHeight: 200, defaultHeaderRowHeight: 30, + defaultColWidth: 280, defaultHeaderColWidth: [80, 'auto', 'auto'], + corner: { titleOnDimension: 'row', headerStyle: { autoWrapText: true, padding: 0 } } + }); + }, + async verify(page) { + // 水平箱线图至少占据两个分组单元格。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 2) throw new Error('水平箱线图单元格缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/box-plot.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/box-plot.mjs new file mode 100644 index 0000000000..40b5bbeda8 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/box-plot.mjs @@ -0,0 +1,48 @@ +/** + * BugServer case IDs: 690325586319a600a9810142 + * 验证目的:PivotChart 的 boxPlot 指标在两级行维度中渲染有离群点和无离群点的数据。 + * 改写:用固定通用类别代替产品文字,保留六个分位数字段的顺序与离群点条件。 + */ +export default { + mount(container) { + // VChart 使用本地 bundle 注册,浏览器不会访问 BugServer 的 CDN。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + return new window.VTable.PivotChart(container, { + rows: [{ dimensionKey: 'group', title: 'Group' }, { dimensionKey: 'region', title: 'Region' }], + columns: [{ dimensionKey: 'category', title: 'Category' }], + indicators: [{ + indicatorKey: 'max', title: 'Distribution', cellType: 'chart', chartModule: 'vchart', + style: { padding: 1 }, + chartSpec: { + type: 'boxPlot', data: { id: 'dataId' }, xField: 'item', + minField: 'min', q1Field: 'q1', medianField: 'median', q3Field: 'q3', + maxField: 'max', outliersField: 'outliers', direction: 'vertical', + boxPlot: { style: { shaftShape: 'line', lineWidth: 2 } } + } + }], + records: [ + { group: 'A', region: 'North', category: 'Device', item: 'Type 1', + min: 4.72, q1: 9.73, median: 10.17, q3: 10.51, max: 11.64, outliers: [12.01, 12.02, 14.03] }, + { group: 'A', region: 'South', category: 'Device', item: 'Type 1', + min: 9.4, q1: 10.06, median: 10.75, q3: 11.56, max: 12.5 }, + { group: 'B', region: 'East', category: 'Device', item: 'Type 2', + min: 8.74, q1: 9.46, median: 10.35, q3: 10.94, max: 12.21 } + ], + indicatorsAsCol: false, defaultRowHeight: 200, defaultHeaderRowHeight: 30, + defaultColWidth: 280, defaultHeaderColWidth: [80, 'auto', 'auto'], + corner: { titleOnDimension: 'row', headerStyle: { autoWrapText: true, padding: 0 } } + }); + }, + async verify(page) { + // 断言 chart 单元格和分位值进入表格,画布有效性由通用检查完成。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 2) throw new Error('箱线图单元格缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/brush-multiple-selection.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/brush-multiple-selection.mjs new file mode 100644 index 0000000000..6e075d03a9 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/brush-multiple-selection.mjs @@ -0,0 +1,71 @@ +/** + * BugServer case IDs: 6954de338ff7e3005dfc6526 + * 验证目的:透视柱状图启用 multiple x 轴框选时,拖动触发 brushEnd 并保留选区。 + * 改写:用三个匿名维度和本地 VChart 构建最小图表,保留实际鼠标拖动。 + */ +export default { + mount(container) { + // 本地图表模块使用两个共享维度指标,两个图表均开启 multiple 框选。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.width = '760px'; + container.style.height = '380px'; + window.__brushEndCount = 0; + // 为两个指标创建相同的横向多次框选配置。 + const chartSpec = (id, field) => ({ + type: 'bar', direction: 'vertical', data: { id }, + xField: 'category', yField: field, + brush: { visible: true, brushType: 'x', brushMode: 'multiple', + inBrush: { colorAlpha: 1 }, outOfBrush: { colorAlpha: 0.2 } }, + animation: false + }); + const table = new window.VTable.PivotChart(container, { + rows: ['group'], columns: ['bucket'], indicatorsAsCol: false, + indicators: [ + { indicatorKey: 'amount', title: 'Amount', cellType: 'chart', chartModule: 'vchart', + chartSpec: chartSpec('amount', 'amount') }, + { indicatorKey: 'count', title: 'Count', cellType: 'chart', chartModule: 'vchart', + chartSpec: chartSpec('count', 'count') } + ], + records: [ + { group: 'All', bucket: 'One', category: 'A', amount: 10, count: 4 }, + { group: 'All', bucket: 'One', category: 'B', amount: 16, count: 8 }, + { group: 'All', bucket: 'One', category: 'C', amount: 12, count: 5 } + ], + defaultRowHeight: 250, defaultColWidth: 500, + defaultHeaderRowHeight: 30, defaultHeaderColWidth: 80, + chartDimensionLinkage: {} + }); + table.onVChartEvent('brushEnd', () => { window.__brushEndCount++; }); + return table; + }, + async exercise(page) { + // 在首个可见图表单元格的绘图区水平拖动创建框选。 + const segment = await page.evaluate(() => { + const table = window.__visualTable; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') { + const b = table.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { start: { x: host.x + b.x1 + 100, + y: host.y + (b.y1 + b.y2) / 2 }, + end: { x: host.x + b.x1 + 360, + y: host.y + (b.y1 + b.y2) / 2 } }; + } + throw new Error('框选图表单元格缺失'); + }); + await page.mouse.move(segment.start.x - 15, segment.start.y); + await page.mouse.move(segment.start.x, segment.start.y, { steps: 5 }); + await page.waitForTimeout(150); + await page.mouse.down(); + await page.mouse.move(segment.end.x, segment.end.y, { steps: 12 }); + await page.mouse.up(); + await page.waitForTimeout(150); + }, + async verify(page) { + // 至少一个图表收到 brushEnd,证明录制的拖动进入了 VChart 框选路径。 + await page.evaluate(() => { + if (window.__brushEndCount < 1) throw new Error('透视图未触发框选结束事件'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/circle-packing-hierarchy.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/circle-packing-hierarchy.mjs new file mode 100644 index 0000000000..86e7ba94f2 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/circle-packing-hierarchy.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 68cba2db5d163300b3f67f84 + * 验证目的:透视图中的 circlePacking 层级数据可绘制叶节点与父节点。 + * 改写:用固定的甲乙组和数值代替来源的大型分类数据。 + */ +export default { + mount(container) { + // 两层子组和四个叶节点足以显示圆形打包图的嵌套关系。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.width = '700px'; + container.style.height = '400px'; + return new window.VTable.PivotChart(container, { + rows: ['group'], columns: ['bucket'], indicatorsAsCol: false, + indicators: [{ indicatorKey: 'name', title: 'Hierarchy', cellType: 'chart', chartModule: 'vchart', + style: { padding: 1 }, chartSpec: { + type: 'circlePacking', data: { id: 'data1' }, categoryField: 'name', valueField: 'value', + circlePacking: { style: { fillOpacity: datum => datum.isLeaf ? 0.75 : 0.25 } }, + layoutPadding: 5, label: { style: { fontSize: 10, visible: datum => datum.depth === 1 } }, + animation: false + } }], + records: [{ group: 'All', bucket: 'One', name: 'root', children: [ + { name: 'Group A', children: [{ name: 'A1', value: 8 }, { name: 'A2', value: 12 }] }, + { name: 'Group B', children: [{ name: 'B1', value: 10 }, { name: 'B2', value: 6 }] } + ] }], + defaultRowHeight: 250, defaultHeaderRowHeight: 40, defaultColWidth: 320, defaultHeaderColWidth: 90 + }); + }, + async verify(page) { + // 层级记录和图表单元格都必须存在,避免仅渲染表头。 + await page.evaluate(() => { + const table = window.__visualTable; + const chartCells = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') chartCells.push({ col, row }); + if (!chartCells.length || table.records[0]?.children?.length !== 2) + throw new Error('圆形打包图的层级记录或图表单元格缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/combination-forecast.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/combination-forecast.mjs new file mode 100644 index 0000000000..9393a68cb3 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/combination-forecast.mjs @@ -0,0 +1,55 @@ +/** + * BugServer case IDs: 66bf0a981140a800e454d5dd + * 验证目的:两个指标各自组合历史柱、趋势线和预测柱,且尺度独立。 + * 改写:匿名化指标与数值,保留三条 series 对应的数据流及年份关系。 + */ +export default { + mount(container) { + // 每个指标使用独立的 common 图表配置,同时包含三种时间段的数据流。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + // 生成第二指标时只替换数据字段,图表组合结构保持一致。 + const indicator = (key, title) => ({ indicatorKey: key, title, cellType: 'chart', + chartModule: 'vchart', style: { padding: 1 }, chartSpec: { + type: 'common', series: [ + { id: key, type: 'bar', data: { id: key }, xField: 'year', yField: key, + barMinWidth: 1, barMaxWidth: 30 }, + { id: `trend_${key}`, type: 'line', data: { id: `trend_${key}` }, + xField: 'year', yField: `trend_${key}`, point: { visible: false }, + line: { style: { stroke: '#999', lineDash: [5], lineWidth: 2 } } }, + { id: `forecast_${key}`, type: 'bar', data: { id: `forecast_${key}` }, + xField: 'year', yField: `forecast_${key}`, + bar: { style: { fill: '#36a269' } } } + ], axes: [{ orient: 'left', min: 0, + seriesId: [key, `trend_${key}`, `forecast_${key}`] }] + } }); + const records = []; + // 固定的历史与预测数据使两个数量级都可稳定比较。 + for (let i = 0; i < 4; i++) { + const year = String(2020 + i); + records.push({ year, quantity: 60 + i * 15 }, { year, trend_quantity: 65 + i * 15 }, + { year, amount: 3600 + i * 800 }, { year, trend_amount: 3800 + i * 750 }); + } + for (let i = 0; i < 3; i++) { + const year = String(2024 + i); + records.push({ year, forecast_quantity: 115 + i * 5 }, + { year, forecast_amount: 5800 + i * 250 }); + } + return new window.VTable.PivotChart(container, { + rows: [], columns: [], indicatorsAsCol: false, records, + indicators: [indicator('quantity', 'Quantity'), indicator('amount', 'Amount')], + defaultRowHeight: 200, defaultColWidth: 400, defaultHeaderRowHeight: 50 + }); + }, + async verify(page) { + // 两个独立 chart 单元格都需要进入布局,避免只渲染单个指标。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 2) throw new Error('组合图指标未完整绘制'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/crosshair-linkage-hover.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/crosshair-linkage-hover.mjs new file mode 100644 index 0000000000..2edd2913df --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/crosshair-linkage-hover.mjs @@ -0,0 +1,61 @@ +/** + * BugServer case IDs: 6916c732711d9200a827cda3 + * 验证目的:两个透视图指标共享维度时,悬停可显示跨图表的准线与提示。 + * 改写:固定 A/B/C 维度和柱线两图,去除来源的大型多指标数据。 + */ +export default { + mount(container) { + // 两个独立图表指标在同一透视表中共用 period 维度。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '430px'; + window.__crosshairHoverCount = 0; + const table = new window.VTable.PivotChart(container, { + rows: [], columns: [], indicatorsAsCol: false, + records: [ + { period: 'A', amount: 10 }, { period: 'B', amount: 18 }, { period: 'C', amount: 13 }, + { period: 'A', rate: 4 }, { period: 'B', rate: 9 }, { period: 'C', rate: 6 } + ], + indicators: [ + { indicatorKey: 'amount', title: 'Amount', cellType: 'chart', chartModule: 'vchart', + chartSpec: { type: 'common', + series: [{ id: 'amount', type: 'bar', data: { id: 'amount' }, + xField: 'period', yField: 'amount' }], + crosshair: { xField: { visible: true } }, animation: false } }, + { indicatorKey: 'rate', title: 'Rate', cellType: 'chart', chartModule: 'vchart', + chartSpec: { type: 'common', + series: [{ id: 'rate', type: 'line', data: { id: 'rate' }, + xField: 'period', yField: 'rate', point: { visible: true } }], + crosshair: { xField: { visible: true } }, animation: false } } + ], + chartDimensionLinkage: { showTooltip: true }, + defaultRowHeight: 180, defaultColWidth: 560, + widthMode: 'autoWidth', heightMode: 'autoHeight' + }); + table.onVChartEvent('pointermove', () => { window.__crosshairHoverCount++; }); + return table; + }, + async exercise(page) { + // 鼠标进入首个图表的绘图区,停留后由截图保存准线与提示状态。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') { + const b = table.getCellRect(col, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + b.x1 + 80, y: host.y + (b.y1 + b.y2) / 2 }; + } + throw new Error('联动图表单元格缺失'); + }); + await page.mouse.move(point.x - 20, point.y); + await page.mouse.move(point.x + 4, point.y, { steps: 8 }); + await page.waitForTimeout(150); + }, + async verify(page) { + // 图表应收到真实指针事件,避免将静态双图误作悬停覆盖。 + await page.evaluate(() => { + if (window.__crosshairHoverCount < 1) + throw new Error('图表未收到悬停事件'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/heatmap-two-indicators.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/heatmap-two-indicators.mjs new file mode 100644 index 0000000000..7109ea1eb8 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/heatmap-two-indicators.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 68cb7de11cfd3a00b25a86ed, 68cb7f3a597cd100b2f74a00, 69899a7209038a005df130d0 + * 验证目的:两个独立记录流的热力图指标与反向颜色图例共同绘制。 + * 改写:三条来源源码完全相同,虽名称不同均为 heatmap;用固定通用字段替换展示文字。 + */ +export default { + mount(container) { + // 来源是两个 indicatorKey 分别绑定两个数据流;共用颜色域但 y 字段不同。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + const makeIndicator = (key, yField) => ({ indicatorKey: key, cellType: 'chart', chartModule: 'vchart', + style: { padding: [1, 1, 0, 1] }, chartSpec: { + type: 'heatmap', direction: 'vertical', xField: 'year', yField, seriesField: 'kind', + valueField: key, padding: 0, data: { id: key }, + axes: [{ type: 'band', orient: 'left', bandPadding: 0 }, + { type: 'band', orient: 'bottom', bandPadding: 0 }], + region: [{ clip: true }], background: 'transparent', + color: { type: 'linear', range: ['#C2CEFF', '#5766EC'], domain: [{ dataId: key, fields: ['colorValue'] }] }, + label: { visible: true, smartInvert: true }, + cell: { style: { shape: 'rect', stroke: '#ffffff', lineWidth: 1, + fill: { field: 'colorValue', scale: 'color' } } } + } }); + return new window.VTable.PivotChart(container, { + rows: [], columns: [], indicators: [makeIndicator('line1', 'salesLabel'), + makeIndicator('line2', 'profitLabel')], + records: { line1: [{ year: '2019', salesLabel: 'Sales', kind: 'sales', line1: 200000, colorValue: 200000 }, + { year: '2020', salesLabel: 'Sales', kind: 'sales', line1: 400000, colorValue: 400000 }], + line2: [{ year: '2019', profitLabel: 'Profit', kind: 'sales', line2: 10000, colorValue: 200000 }, + { year: '2020', profitLabel: 'Profit', kind: 'sales', line2: 20000, colorValue: 400000 }] }, + widthMode: 'adaptive', heightMode: 'adaptive', indicatorsAsCol: false, + legends: { visible: true, type: 'color', orient: 'right', position: 'start', + colors: ['#C2CEFF', '#5766EC'], value: [1000000, 200000], min: 1000000, max: 200000, maxWidth: '30%' } + }); + }, + async verify(page) { + // 两个指标都必须产出 chart 单元格,不能只展示图例。 + await page.evaluate(() => { + const table = window.__visualTable; + let count = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) if (table.getCellType(col, row) === 'chart') count++; + if (count < 2) throw new Error('双热力图指标缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/histogram-color.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/histogram-color.mjs new file mode 100644 index 0000000000..1388d516f5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/histogram-color.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 69290d62e84559005d355243 + * 验证目的:透视直方图按指定 ordinal 颜色域绘制含零值的多个区间。 + * 改写:用固定通用形状与区间数值代替来源数据,增加固定行维度保证图表可见,保留 bin 起止字段和颜色配置。 + */ +export default { + mount(container) { + // 两种形状分别落在独立图表单元格内,零值区间检验空柱绘制。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + const records = []; + // 每种形状都有相同 bin 边界,但各自数据量不同。 + for (const [shape, values] of [['Circle', [6, 2, 0, 4]], ['Square', [3, 0, 5, 1]]]) + values.forEach((value, index) => records.push({ group: 'All', shape, from: index + 1, + to: index + 2, value })); + return new window.VTable.PivotChart(container, { + rows: ['group'], columns: ['shape'], + indicators: [{ indicatorKey: 'value', title: 'Value', cellType: 'chart', chartModule: 'vchart', + chartSpec: { type: 'histogram', xField: 'from', x2Field: 'to', + yField: 'value', seriesField: 'shape', data: { id: 'data1' }, barGap: 2, + color: { type: 'ordinal', domain: ['Circle', 'Square'], range: ['red', 'blue'] }, + legends: { visible: false } }, style: { padding: [1, 1, 0, 1] } }], + records, indicatorsAsCol: false, defaultRowHeight: 230, defaultColWidth: 290, + defaultHeaderRowHeight: 40, defaultHeaderColWidth: 80 + }); + }, + async verify(page) { + // 两组 bin 数据都需要成为 chart 单元格,颜色和空柱由截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + const rect = table.getCellRect(2, 1).bounds; + if (charts < 2 || rect.x2 > 800 || rect.y2 > 400) + throw new Error(`直方图单元格未完整进入视口:${JSON.stringify(rect)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/histogram-overlap.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/histogram-overlap.mjs new file mode 100644 index 0000000000..67f4f6a14e --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/histogram-overlap.mjs @@ -0,0 +1,38 @@ +/** + * BugServer case IDs: 68cba1911ef44500a8bec33f + * 验证目的:透视直方图的多个系列存在重叠区间时,区间端点与指标仍被绘制。 + * 改写:来源的大量重复区间缩减到跨城市、跨类别的代表组合,并缩小标题尺寸让柱体进入截图。 + */ +export default { + mount(container) { + // 保留 0-100 与 50-150 的重叠,以及同区间不同系列。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + return new window.VTable.PivotChart(container, { + columns: ['category'], rows: ['city'], + indicators: [{ indicatorKey: 'profit', title: 'Value', cellType: 'chart', chartModule: 'vchart', + headerStyle: { color: 'red', borderLineWidth: [1, 0, 1, 0], autoWrapText: true }, + style: { padding: 1 }, chartSpec: { type: 'histogram', xField: 'from', x2Field: 'to', + yField: 'profit', seriesField: 'type', data: { id: 'data1' }, + bar: { style: { stroke: 'white', lineWidth: 1 } } } }], + records: [{ from: 0, to: 100, profit: 300, type: 'A', city: 'North', category: 'Device' }, + { from: 0, to: 100, profit: 100, type: 'B', city: 'North', category: 'Device' }, + { from: 50, to: 150, profit: 30, type: 'B', city: 'North', category: 'Device' }, + { from: 120, to: 130, profit: 150, type: 'D', city: 'North', category: 'Device' }, + { from: 10, to: 16, profit: 3, type: 'B', city: 'South', category: 'Office' }, + { from: 16, to: 18, profit: 15, type: 'C', city: 'South', category: 'Office' }], + indicatorsAsCol: false, defaultRowHeight: 200, defaultColWidth: 280, + defaultHeaderRowHeight: 40, defaultHeaderColWidth: 80 + }); + }, + async verify(page) { + // 两个透视分组都必须生成图表格。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 2) throw new Error('重叠区间直方图缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/mark-line.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/mark-line.mjs new file mode 100644 index 0000000000..c637d93028 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/mark-line.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 6614f1552acd9c00d1c98a65 + * 验证目的:透视柱状图同时绘制汇总标记线、百分比端点和起始三角符号。 + * 改写:数值与日期为固定示例,去除无关主题和远程 VChart 加载。 + */ +export default { + mount(container) { + // 本地 VChart bundle 使标记线与图表进入相同的视觉快照。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + return new window.VTable.PivotChart(container, { + rows: [], columns: [], indicatorsAsCol: false, + records: [ + { day: '2023-12-25', count: 6 }, { day: '2023-12-26', count: 59 }, + { day: '2023-12-28', count: 29 }, { day: '2023-12-29', count: 82 }, + { day: '2023-12-30', count: 107 } + ], + indicators: [{ indicatorKey: 'count', title: 'Count', cellType: 'chart', chartModule: 'vchart', + style: { padding: 1 }, chartSpec: { + type: 'common', + series: [{ id: 'count', type: 'bar', data: { id: 'count' }, xField: 'day', yField: 'count', + barMinWidth: 1, barMaxWidth: 30, barMinHeight: 1 }], + axes: [{ orient: 'left', min: 0, seriesId: ['count'] }], + markLine: [{ y1: 'sum', x: '2023-12-25', y: '10%', x1: '2023-12-30', + autoRange: true, relativeSeriesIndex: 0, + startSymbol: { visible: true, style: { symbolType: 'triangleDown', size: 8 } }, + endSymbol: { visible: false }, + line: { style: { stroke: '#000', lineDash: [5], lineWidth: 2 } }, + label: { visible: true, position: 'insideStartTop' } }] + } }], + defaultRowHeight: 200, defaultColWidth: 560, defaultHeaderRowHeight: 50, + widthMode: 'standard', heightMode: 'autoHeight' + }); + }, + async verify(page) { + // 柱状图单元格必须实际存在,标记线形状由快照比较。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 1) throw new Error('标记线图表单元格缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/pie-multi-select.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/pie-multi-select.mjs new file mode 100644 index 0000000000..bd12821da2 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/pie-multi-select.mjs @@ -0,0 +1,63 @@ +/** + * BugServer case IDs: 696dd1827a558400611ce035 + * 验证目的:透视饼图连续点击多个扇区时保留 element-select 多选状态。 + * 改写:将来源的区域和度量字段换成固定 A/B/C/D 类别与整数。 + */ +export default { + mount(container) { + // 多选交互通过图元状态改变透明度,最终状态由像素快照保存。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.width = '700px'; + container.style.height = '400px'; + window.__pieClicks = []; + const table = new window.VTable.PivotChart(container, { + rows: ['group'], columns: ['bucket'], indicatorsAsCol: false, + indicators: [{ indicatorKey: 'value', title: 'Amount', cellType: 'chart', + chartModule: 'vchart', style: { padding: 1 }, chartSpec: { + type: 'pie', data: { id: 'data1' }, categoryField: 'category', valueField: 'value', + outerRadius: 0.8, innerRadius: 0, + pie: { state: { selected: { opacity: 1 }, selected_reverse: { opacity: 0.2 } } }, + interactions: [{ type: 'element-select', isMultiple: true, triggerOff: 'empty' }], + animation: false + } }], + records: [ + { group: 'All', bucket: 'One', category: 'A', value: 6 }, + { group: 'All', bucket: 'One', category: 'B', value: 9 }, + { group: 'All', bucket: 'One', category: 'C', value: 12 }, + { group: 'All', bucket: 'One', category: 'D', value: 8 } + ], + defaultRowHeight: 230, defaultColWidth: 290, + defaultHeaderRowHeight: 40, defaultHeaderColWidth: 80, + chartDimensionLinkage: { showTooltip: true } + }); + table.onVChartEvent('click', args => { window.__pieClicks.push(args?.datum?.category ?? 'chart'); }); + return table; + }, + async exercise(page) { + // 用图元数据求真实扇区坐标,避免图表在不同平台的单元格内偏移影响命中。 + const points = await page.evaluate(() => { + const table = window.__visualTable; + const cell = Array.from({ length: table.rowCount }, (_, row) => + Array.from({ length: table.colCount }, (_, col) => ({ col, row }))) + .flat().find(({ col, row }) => table.getCellType(col, row) === 'chart'); + if (!cell) throw new Error('饼图单元格未渲染'); + const paths = table.getCellHeaderPaths(cell.col, cell.row); + const host = document.getElementById('table').getBoundingClientRect(); + return [{ category: 'A', value: 6 }, { category: 'B', value: 9 }, { category: 'C', value: 12 }] + .map(datum => { + const position = table.getChartDatumPosition({ group: 'All', bucket: 'One', ...datum }, paths); + if (!position) throw new Error(`无法定位扇区 ${datum.category}`); + return { x: host.x + position.x, y: host.y + position.y }; + }); + }); + for (const point of points) await page.mouse.click(point.x, point.y); + await page.mouse.move(650, 350); + }, + async verify(page) { + // 至少两个不同图元必须收到真实图表点击,避免点击到空白区域。 + await page.evaluate(() => { + if (new Set(window.__pieClicks).size < 2) + throw new Error(`饼图多选未点击不同扇区:${JSON.stringify(window.__pieClicks)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/rose-indicator-title.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/rose-indicator-title.mjs new file mode 100644 index 0000000000..c41c5cfbb3 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/rose-indicator-title.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 695347df862ee80066b9cc60 + * 验证目的:PivotChart 玫瑰图显示指标标题和两组扇形数据。 + * 改写:保留 rose、角度及系列字段,删除来源的大型指标数据。 + */ +export default { + mount(container) { + // 使用本地 VChart 绘制 rose,避免来源的远程依赖和动画波动。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + return new window.VTable.PivotChart(container, { + rows: ['group'], columns: ['period'], indicatorsAsCol: false, hideIndicatorName: false, + indicators: [{ indicatorKey: 'value', title: 'Amount', cellType: 'chart', chartModule: 'vchart', + chartSpec: { type: 'rose', data: { id: 'data' }, categoryField: 'category', + valueField: 'value', seriesField: 'series', outerRadius: 0.82, + innerRadius: 0, animation: false } }], + records: [ + { group: 'G', period: 'P', category: 'A', series: 'One', value: 7 }, + { group: 'G', period: 'P', category: 'B', series: 'One', value: 11 }, + { group: 'G', period: 'P', category: 'C', series: 'One', value: 5 }, + { group: 'G', period: 'P', category: 'A', series: 'Two', value: 4 }, + { group: 'G', period: 'P', category: 'B', series: 'Two', value: 8 }, + { group: 'G', period: 'P', category: 'C', series: 'Two', value: 10 } + ], + defaultRowHeight: 300, defaultHeaderRowHeight: 40, + defaultColWidth: 480, defaultHeaderColWidth: 130, widthMode: 'standard' + }); + }, + async verify(page) { + // 指标标题与 rose 图表单元格必须进入实际布局。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 1 || table.colCount < 1 || table.rowCount < 2) + throw new Error('玫瑰图或指标标题未建立'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/scatter-axes.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/scatter-axes.mjs new file mode 100644 index 0000000000..91229d8e19 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/scatter-axes.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 6690fbcd49841700ac76649e + * 验证目的:同一透视图中 bar 和 scatter 指标各自绑定 seriesId 轴。 + * 改写:类别改为通用文字,保留两个不同量级数据流、左右轴配置与自动尺寸。 + */ +export default { + mount(container) { + // 分开两个记录流,确保 bar 与 scatter 不会错误共享数值轴。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + const chart = (key, type) => ({ indicatorKey: key, title: key, cellType: 'chart', chartModule: 'vchart', + chartSpec: { type: 'common', series: [{ id: key, type, data: { id: key }, + xField: 'group', yField: key, ...(type === 'scatter' ? { point: { style: { size: 8 } } } : {}) }], + axes: [{ orient: 'left', min: 0, seriesId: [key] }, + { orient: 'right', visible: false, seriesId: [key] }] }, + style: { padding: 1 } }); + return new window.VTable.PivotChart(container, { + indicatorsAsCol: false, rows: [], columns: [], + records: [{ group: 'A', count: 11581 }, { group: 'B', count: 19173 }, + { group: 'C', count: 6780 }, { group: 'A', value: 681967.69 }, + { group: 'B', value: 1053092.69 }, { group: 'C', value: 412478.55 }], + indicators: [chart('count', 'bar'), chart('value', 'scatter')], + corner: { titleOnDimension: 'row', disableHeaderHover: true }, + widthMode: 'autoWidth', heightMode: 'autoHeight', defaultRowHeight: 200, + defaultHeaderRowHeight: 50, defaultColWidth: 280, defaultHeaderColWidth: 120, + autoFillWidth: true, autoFillHeight: true, tooltip: { confine: false }, + hover: { disableAxisHover: true } + }); + }, + async verify(page) { + // bar 和 scatter 均须成为真实的 chart 单元格。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 2) throw new Error('双轴散点透视图缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot-chart/sunburst-hierarchy.mjs b/packages/vtable/__tests__/visual/cases/pivot-chart/sunburst-hierarchy.mjs new file mode 100644 index 0000000000..0489f69f5d --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot-chart/sunburst-hierarchy.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 68cba293e9f70b00b11e93d5 + * 验证目的:行列维度下的 sunburst 图表读取嵌套 children 数据并绘制层级扇区。 + * 改写:原有城市与类别改为通用分组,保留三层数据树。 + */ +export default { + mount(container) { + // 两个维度单元格各使用不同层级值,防止退化为单一扇区。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + container.style.height = '400px'; + return new window.VTable.PivotChart(container, { + rows: ['group'], columns: ['category'], indicatorsAsCol: false, + indicators: [{ indicatorKey: 'name', title: 'Hierarchy', cellType: 'chart', chartModule: 'vchart', + style: { padding: 1 }, chartSpec: { type: 'sunburst', + categoryField: 'name', valueField: 'value', outerRadius: 1, innerRadius: 0, + data: { id: 'data1' } } }], + records: [ + { name: 'Collection A', group: 'Group A', category: 'Type 1', children: [ + { name: 'North', children: [{ name: 'One', value: 80 }, { name: 'Two', value: 120 }] }, + { name: 'South', children: [{ name: 'One', value: 60 }, { name: 'Two', value: 150 }] } + ] }, + { name: 'Collection B', group: 'Group B', category: 'Type 1', children: [ + { name: 'East', children: [{ name: 'One', value: 90 }, { name: 'Two', value: 75 }] }, + { name: 'West', children: [{ name: 'One', value: 55 }, { name: 'Two', value: 110 }] } + ] } + ], defaultRowHeight: 220, defaultColWidth: 280, defaultHeaderRowHeight: 35 + }); + }, + async verify(page) { + // 层级维度下必须有两个 chart 单元格;扇区层级由截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + if (charts < 2) throw new Error('Sunburst 层级图表单元格缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/aggregation-rules.mjs b/packages/vtable/__tests__/visual/cases/pivot/aggregation-rules.mjs new file mode 100644 index 0000000000..5999dd3f3c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/aggregation-rules.mjs @@ -0,0 +1,41 @@ +/** + * BugServer case IDs: 64ed9179d4858ce3fbd85ada + * 验证目的:同一个 sales 字段按 SUM、COUNT、AVG 三种聚合规则形成指标行。 + * 改写:使用通用数值和维度,保留三种 aggregationType 及独立数字格式。 + */ +export default { + mount(container) { + // 重复维度组合中的 10 和 20 应分别聚合为 30、2、15。 + const VTable = window.VTable; + return new VTable.PivotTable(container, { + rows: ['region', 'site'], columns: ['category', 'variant'], + indicators: ['Total', 'Count', 'Average'], enableDataAnalysis: true, + indicatorTitle: 'Metric', indicatorsAsCol: false, + dataConfig: { aggregationRules: [ + { indicatorKey: 'Total', field: 'sales', aggregationType: VTable.TYPES.AggregationType.SUM, + formatFun: VTable.DataStatistics.numberFormat({ suffix: ' units' }) }, + { indicatorKey: 'Count', field: 'sales', aggregationType: VTable.TYPES.AggregationType.COUNT, + formatFun: VTable.DataStatistics.numberFormat({ digitsAfterDecimal: 0, thousandsSep: '', suffix: ' items' }) }, + { indicatorKey: 'Average', field: 'sales', aggregationType: VTable.TYPES.AggregationType.AVG, + formatFun: VTable.DataStatistics.numberFormat({ suffix: ' units' }) } + ] }, + corner: { titleOnDimension: 'row' }, + records: [{ region: 'A', site: 'A1', category: 'X', variant: 'X1', sales: 10 }, + { region: 'A', site: 'A1', category: 'X', variant: 'X1', sales: 20 }, + { region: 'A', site: 'A2', category: 'X', variant: 'X1', sales: 40 }, + { region: 'B', site: 'B1', category: 'X', variant: 'X1', sales: 5 }], + widthMode: 'autoWidth' + }); + }, + async verify(page) { + // 聚合后的三个结果都必须出现在实际单元格中。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(Number(table.getCellOriginValue(col, row))); + if (![30, 2, 15].every(value => values.includes(value))) + throw new Error(`SUM/COUNT/AVG 聚合结果缺失: ${JSON.stringify(values)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/analysis-two-level.mjs b/packages/vtable/__tests__/visual/cases/pivot/analysis-two-level.mjs new file mode 100644 index 0000000000..504657eb82 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/analysis-two-level.mjs @@ -0,0 +1,38 @@ +/** + * BugServer case IDs: 64ed8d04d4858ce3fbd85ad4 + * 验证目的:两级行列维度、两个指标与数据分析在透视表中共同生效。 + * 改写:将原例的地区销售明细换成固定的通用 A/B 数据,保留维度层级和指标结构。 + */ +export default { + mount(container) { + // 使用完整的两级交叉组合,避免缺失组合掩盖聚合错误。 + const records = [ + { group: 'A', item: 'A1', kind: 'X', variant: 'X1', amount: 10, count: 1 }, + { group: 'A', item: 'A2', kind: 'X', variant: 'X2', amount: 20, count: 2 }, + { group: 'B', item: 'B1', kind: 'Y', variant: 'Y1', amount: 30, count: 3 }, + { group: 'B', item: 'B2', kind: 'Y', variant: 'Y2', amount: 40, count: 4 } + ]; + return new window.VTable.PivotTable(container, { + rows: ['group', 'item'], + columns: ['kind', 'variant'], + indicators: ['amount', 'count'], + enableDataAnalysis: true, + indicatorTitle: 'Measure', + indicatorsAsCol: false, + corner: { titleOnDimension: 'row' }, + records, + widthMode: 'autoWidth' + }); + }, + async verify(page) { + // 检查聚合后的数值确实进入可见单元格,并验证行列层级。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount < 4 || table.colCount < 4) throw new Error('透视维度未展开'); + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellOriginValue(col, row)); + if (!values.includes(10) || !values.includes(40)) throw new Error('透视聚合值缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/calculated-field-hidden-dependencies.mjs b/packages/vtable/__tests__/visual/cases/pivot/calculated-field-hidden-dependencies.mjs new file mode 100644 index 0000000000..3c2cab6df8 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/calculated-field-hidden-dependencies.mjs @@ -0,0 +1,39 @@ +/** + * BugServer case IDs: 667283a571989000cfc6dc95 + * 验证目的:计算字段可引用未列为显示指标的两个原始字段。 + * 改写:将地区明细缩成固定通用维度,保留 sales/number 的除法和行列合计。 + */ +export default { + mount(container) { + // 显示列表只有计算指标,依赖字段仍留在原始记录中。 + return new window.VTable.PivotTable(container, { + rows: ['group', 'item'], columns: ['kind', 'variant'], + indicators: [{ indicatorKey: 'unitPrice', title: 'Unit price', format: value => '$' + Number(value).toFixed(2) }], + enableDataAnalysis: true, indicatorTitle: 'Measure', indicatorsAsCol: false, + dataConfig: { + calculatedFieldRules: [{ key: 'unitPrice', dependIndicatorKeys: ['sales', 'number'], + calculateFun: value => value.sales / value.number }], + totals: { row: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['group'], + grandTotalLabel: 'Total', subTotalLabel: 'Subtotal' }, + column: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['kind'], + grandTotalLabel: 'Total', subTotalLabel: 'Subtotal' } } + }, + corner: { titleOnDimension: 'row' }, + records: [{ group: 'A', item: 'A1', kind: 'X', variant: 'X1', sales: 10, number: 2 }, + { group: 'A', item: 'A2', kind: 'X', variant: 'X2', sales: 30, number: 3 }, + { group: 'B', item: 'B1', kind: 'Y', variant: 'Y1', sales: 12, number: 4 }], + widthMode: 'autoWidth' + }); + }, + async verify(page) { + // 必须读到至少一个算出的 5 和 10,避免只验证配置存在。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(Number(table.getCellOriginValue(col, row))); + if (!values.some(value => Math.abs(value - 5) < 1e-9) || !values.some(value => Math.abs(value - 10) < 1e-9)) + throw new Error('隐藏依赖字段的计算结果缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/chart-spec-mark-line.mjs b/packages/vtable/__tests__/visual/cases/pivot/chart-spec-mark-line.mjs new file mode 100644 index 0000000000..00fad71032 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/chart-spec-mark-line.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 65d572a597cc3d008de5b5a4 + * 验证目的:PivotTable 图表指标使用回调生成面积图,并绘制固定阈值标记线。 + * 改写:将来源的气温表缩为两组固定数值,去除远程图表加载。 + */ +export default { + mount(container) { + // 图表数据由记录的 trend 数组提供,markLine 在每个透视单元格内绘制。 + window.VTable.register.chartModule('vchart', window.VChart.default ?? window.VChart); + window.__pivotChartSpecCalls = 0; + return new window.VTable.PivotTable(container, { + rowTree: [{ dimensionKey: 'group', value: 'A' }, { dimensionKey: 'group', value: 'B' }], + columnTree: [{ indicatorKey: 'trend', value: 'Trend' }], + indicators: [{ indicatorKey: 'trend', title: 'Trend', cellType: 'chart', chartModule: 'vchart', + chartSpec: () => { + window.__pivotChartSpecCalls++; + return { type: 'area', data: { id: 'data' }, xField: 'period', yField: 'value', + animation: false, markLine: [{ y: 5, + line: { style: { stroke: '#222', lineDash: [5, 5], lineWidth: 2 } } }] }; + } }], + records: [ + { group: 'A', trend: [{ period: 'P1', value: 2 }, { period: 'P2', value: 7 }, { period: 'P3', value: 4 }] }, + { group: 'B', trend: [{ period: 'P1', value: 3 }, { period: 'P2', value: 6 }, { period: 'P3', value: 8 }] } + ], + defaultRowHeight: 190, defaultColWidth: 530, defaultHeaderColWidth: 80 + }); + }, + async verify(page) { + // 运行时须进入图表配置回调并建立两个图表单元格。 + await page.evaluate(() => { + const table = window.__visualTable; + let charts = 0; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) + if (table.getCellType(col, row) === 'chart') charts++; + if (charts !== 2 || window.__pivotChartSpecCalls < 2) + throw new Error('PivotTable 图表回调或标记线单元格缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/color-legend-order.mjs b/packages/vtable/__tests__/visual/cases/pivot/color-legend-order.mjs new file mode 100644 index 0000000000..83c504486d --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/color-legend-order.mjs @@ -0,0 +1,35 @@ +/** + * BugServer case IDs: 6979f44b20f246006732267e + * 验证目的:PivotTable 的标题先于顶部连续色彩图例布局。 + * 改写:用四条匿名记录替代来源的大型数值表和远程图表资源。 + */ +export default { + mount(container) { + // 保留 title、color legends 和 componentLayoutOrder 的组合。 + return new window.VTable.PivotTable(container, { + rows: ['group'], columns: ['category'], indicators: ['amount'], + records: [ + { group: 'A', category: 'X', amount: 20 }, + { group: 'A', category: 'Y', amount: 40 }, + { group: 'B', category: 'X', amount: 60 }, + { group: 'B', category: 'Y', amount: 80 } + ], + title: { text: 'Color range', orient: 'top', align: 'center' }, + legends: { type: 'color', orient: 'top', position: 'start', + colors: ['rgb(235,235,255)', 'rgb(35,35,255)'], value: [0, 100], min: 0, max: 100 }, + componentLayoutOrder: ['title', 'legend'], + defaultColWidth: 160, defaultHeaderColWidth: 100 + }); + }, + async verify(page) { + // 检查图例生成可见边界,绘制顺序由截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + const legend = table.internalProps.legends?.[0]?.legendComponent; + if (!legend || legend.AABBBounds.width() < 20 || legend.AABBBounds.height() < 10) + throw new Error('连续色彩图例未绘制'); + if (!table.internalProps.title || table.getDrawRange().top < 30) + throw new Error('标题与图例未占据顶部布局'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/column-header-title.mjs b/packages/vtable/__tests__/visual/cases/pivot/column-header-title.mjs new file mode 100644 index 0000000000..d41e2c2dbf --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/column-header-title.mjs @@ -0,0 +1,52 @@ +/** + * BugServer case IDs: 6474534d5d221c008666ee18 + * 验证目的:columnHeaderTitle 在列维度上方增加独立标题行。 + * 改写:将来源的大型主题和数据矩阵缩为固定匿名记录,对照无标题配置。 + */ +export default { + mount(container) { + // 两张透视表共享记录,仅切换 columnHeaderTitle 以隔离标题行效果。 + container.style.width = '800px'; + container.style.height = '330px'; + container.style.display = 'grid'; + container.style.gridTemplateColumns = '1fr 1fr'; + const records = [ + { group: 'A', period: 'First', amount: 10 }, + { group: 'A', period: 'Second', amount: 20 }, + { group: 'B', period: 'First', amount: 30 } + ]; + const tables = [false, true].map(withTitle => { + const host = document.createElement('div'); + host.style.cssText = 'width:390px;height:320px'; + container.append(host); + return new window.VTable.PivotTable(host, { + rows: [{ dimensionKey: 'group', title: 'Group' }], + columns: [{ dimensionKey: 'period', title: 'Period' }], + indicators: [{ indicatorKey: 'amount', title: 'Amount' }], + records, + ...(withTitle ? { columnHeaderTitle: { title: 'Periods', headerStyle: { textAlign: 'center' } } } : {}), + widthMode: 'standard' + }); + }); + return { + tables, + async renderAsync() { + // 等待两张画布都完成绘制后再截图。 + await Promise.all(tables.map(table => table.renderAsync?.())); + }, + release() { + // 用例结束时释放两张透视表。 + tables.forEach(table => table.release()); + } + }; + }, + async verify(page) { + // 标题配置应比对照表多一层列头,且两张表都具有真实画布。 + await page.evaluate(() => { + const [plain, titled] = window.__visualTable.tables; + if (titled.columnHeaderLevelCount !== plain.columnHeaderLevelCount + 1 || + !plain.canvas?.width || !titled.canvas?.width) + throw new Error('列头标题未增加独立表头层'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/corner-empty-matrix.mjs b/packages/vtable/__tests__/visual/cases/pivot/corner-empty-matrix.mjs new file mode 100644 index 0000000000..a68a77e133 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/corner-empty-matrix.mjs @@ -0,0 +1,69 @@ +/** + * BugServer case IDs: 67eb9a84fddcfa00a827d1e5, 67eb9aad731cd600a72b8541, 67eb9b1d2b0c7600a7633c83, 67eb9b4ab8637b00a7a32276, 67eb9baddc8abb00a7c081f1, 67eb9bcdd3963500b3ec8261 + * 验证目的:无行、无列与无指标的角头组合均可实际绘制。 + * 改写:六个相邻 BugServer 配置共享匿名二维数据,在一张快照中并排检查。 + */ +export default { + mount(container) { + // 各子表仍由真正的 PivotTable 绘制,共享的仅是无业务含义的输入记录。 + container.style.width = '800px'; + container.style.height = '640px'; + container.style.display = 'grid'; + container.style.gridTemplateColumns = '1fr 1fr'; + container.style.gridTemplateRows = 'repeat(3, 210px)'; + const records = [ + { region: 'A', item: 'X', type: 'T1', subtype: 'S1', amount: 5 }, + { region: 'A', item: 'Y', type: 'T1', subtype: 'S2', amount: 7 }, + { region: 'B', item: 'Z', type: 'T2', subtype: 'S1', amount: 12 } + ]; + const variants = [ + { label: 'no rows / no columns / indicator column', indicatorsAsCol: true, + corner: { titleOnDimension: 'none', forceShowHeader: true } }, + { label: 'no rows / no columns / indicator row', indicatorsAsCol: false, + corner: { titleOnDimension: 'none', forceShowHeader: true } }, + { label: 'columns only', columns: ['type', 'subtype'], indicatorsAsCol: false, + corner: { titleOnDimension: 'column', forceShowHeader: true } }, + { label: 'rows only', rows: ['region', 'item'], indicatorsAsCol: true, + corner: { titleOnDimension: 'row', forceShowHeader: true } }, + { label: 'rows / no indicator', rows: ['region', 'item'], indicators: [], + corner: { titleOnDimension: 'row', forceShowHeader: true } }, + { label: 'columns / no indicator', columns: ['type', 'subtype'], indicators: [], + corner: { titleOnDimension: 'column', forceShowHeader: true } } + ]; + const tables = variants.map(variant => { + const panel = document.createElement('div'); + panel.style.cssText = 'height:205px;overflow:hidden;border:1px solid #bbb'; + const label = document.createElement('div'); + label.textContent = variant.label; + label.style.cssText = 'height:22px;font:12px sans-serif'; + const host = document.createElement('div'); + host.style.cssText = 'width:100%;height:180px'; + panel.append(label, host); + container.append(panel); + const { label: ignored, ...options } = variant; + return new window.VTable.PivotTable(host, { + records, indicators: ['amount'], widthMode: 'standard', ...options + }); + }); + return { + tables, + async renderAsync() { + // 等待六张子表都完成绘制后再进入截图阶段。 + await Promise.all(tables.map(table => table.renderAsync?.())); + }, + release() { + // 用例结束时逐个释放子表,避免多实例残留。 + tables.forEach(table => table.release()); + } + }; + }, + async verify(page) { + // 六种配置均须有可见行列与实际画布,避免仅有外层标签通过截图。 + await page.evaluate(() => { + const tables = window.__visualTable.tables; + if (tables.length !== 6 || tables.some(table => table.rowCount < 1 || table.colCount < 1 || + !table.canvas?.width || !table.canvas?.height)) + throw new Error('空维度矩阵有未绘制的透视表'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/corner-remaining-variants.mjs b/packages/vtable/__tests__/visual/cases/pivot/corner-remaining-variants.mjs new file mode 100644 index 0000000000..713ca04474 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/corner-remaining-variants.mjs @@ -0,0 +1,53 @@ +/** + * BugServer case IDs: 67eba18063ae2300a3122147, 67eba1e52ef4e200a7775a9c, 67ecf4e1d4307700b694535e + * 验证目的:角头在只有行维度、只有列维度和无指标的其余排列中仍能绘制。 + * 改写:来源的地区与商品明细统一替换为 A/B、X/Y 匿名组合。 + */ +export default { + mount(container) { + // 三张表分别覆盖指标在行、指标在列及无指标且只有一级行维度。 + container.style.width = '800px'; + container.style.height = '660px'; + container.style.display = 'grid'; + container.style.gridTemplateRows = 'repeat(3, 220px)'; + const records = [ + { group: 'A', item: 'A1', type: 'X', subtype: 'X1', amount: 10 }, + { group: 'A', item: 'A2', type: 'X', subtype: 'X2', amount: 20 }, + { group: 'B', item: 'B1', type: 'Y', subtype: 'Y1', amount: 30 } + ]; + const variants = [ + { rows: ['group', 'item'], indicators: ['amount'], indicatorsAsCol: false, + corner: { titleOnDimension: 'row', forceShowHeader: true } }, + { columns: ['type', 'subtype'], indicators: ['amount'], indicatorsAsCol: true, + corner: { titleOnDimension: 'column', forceShowHeader: true } }, + { rows: ['group'], indicators: [], indicatorsAsCol: true, + corner: { titleOnDimension: 'row', forceShowHeader: true } } + ]; + const tables = variants.map(variant => { + const host = document.createElement('div'); + host.style.cssText = 'width:780px;height:210px'; + container.append(host); + return new window.VTable.PivotTable(host, { records, ...variant, widthMode: 'standard' }); + }); + return { + tables, + async renderAsync() { + // 等待三个布局各自完成绘制。 + await Promise.all(tables.map(table => table.renderAsync?.())); + }, + release() { + // 测试结束后释放全部透视表。 + tables.forEach(table => table.release()); + } + }; + }, + async verify(page) { + // 三种空维度排列均须生成有效行列和画布。 + await page.evaluate(() => { + const tables = window.__visualTable.tables; + if (tables.length !== 3 || tables.some(table => + table.rowCount < 1 || table.colCount < 1 || !table.canvas?.width)) + throw new Error('角头补充排列存在未绘制表格'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/corner-sort-one-dimension.mjs b/packages/vtable/__tests__/visual/cases/pivot/corner-sort-one-dimension.mjs new file mode 100644 index 0000000000..4e772d8217 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/corner-sort-one-dimension.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 69d5d35be48f3a005ed8b838 + * 验证目的:角头启用排序图标时,透视排序状态可指向单级角头维度。 + * 改写:使用固定 A/B 分类与整数指标,保留 showSortInCorner 和 isPivotCorner 路径。 + */ +export default { + mount(container) { + // 两级行维度与单级列维度共同显示角头排序状态及两个数值指标。 + container.style.width = '800px'; + container.style.height = '400px'; + return new window.VTable.PivotTable(container, { + records: [ + { category: 'A', item: 'A1', sales: 100, profit: 10 }, + { category: 'A', item: 'A2', sales: 150, profit: 15 }, + { category: 'B', item: 'B1', sales: 200, profit: 20 }, + { category: 'B', item: 'B2', sales: 250, profit: 25 } + ], + rows: [ + { dimensionKey: 'item', title: 'Item', showSortInCorner: true }, + { dimensionKey: 'category', title: 'Category', showSortInCorner: true } + ], + columns: [{ dimensionKey: 'category', title: 'Category', showSortInCorner: true }], + indicators: [ + { indicatorKey: 'sales', title: 'Sales', showSortInCorner: true }, + { indicatorKey: 'profit', title: 'Profit', showSortInCorner: true } + ], + corner: { titleOnDimension: 'all' }, + pivotSortState: [{ dimensions: [{ dimensionKey: 'category', isPivotCorner: true }], order: 'desc' }] + }); + }, + async verify(page) { + // 角头排序路径和指标值都应被实际透视表接收并绘制。 + await page.evaluate(() => { + const table = window.__visualTable; + const state = table.pivotSortState?.[0]; + if (state?.order !== 'desc' || state.dimensions?.length !== 1 || + state.dimensions.some(d => !d.isPivotCorner) || table.rowCount < 3) + throw new Error(`角头排序状态缺失:${JSON.stringify(table.pivotSortState)}`); + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellOriginValue(col, row)); + if (!values.includes(100) || !values.includes(250)) throw new Error('角头排序的指标数据缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/corner-sort-two-dimensions.mjs b/packages/vtable/__tests__/visual/cases/pivot/corner-sort-two-dimensions.mjs new file mode 100644 index 0000000000..8b76473a50 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/corner-sort-two-dimensions.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 69d5d380361ead005d3155e3 + * 验证目的:角头启用排序图标时,透视排序状态可指向两级角头维度。 + * 改写:使用固定 A/B 分类与整数指标,保留 showSortInCorner 和 isPivotCorner 路径。 + */ +export default { + mount(container) { + // 两级行维度与单级列维度共同显示角头排序状态及两个数值指标。 + container.style.width = '800px'; + container.style.height = '400px'; + return new window.VTable.PivotTable(container, { + records: [ + { category: 'A', item: 'A1', sales: 100, profit: 10 }, + { category: 'A', item: 'A2', sales: 150, profit: 15 }, + { category: 'B', item: 'B1', sales: 200, profit: 20 }, + { category: 'B', item: 'B2', sales: 250, profit: 25 } + ], + rows: [ + { dimensionKey: 'item', title: 'Item', showSortInCorner: true }, + { dimensionKey: 'category', title: 'Category', showSortInCorner: true } + ], + columns: [{ dimensionKey: 'category', title: 'Category', showSortInCorner: true }], + indicators: [ + { indicatorKey: 'sales', title: 'Sales', showSortInCorner: true }, + { indicatorKey: 'profit', title: 'Profit', showSortInCorner: true } + ], + corner: { titleOnDimension: 'all' }, + pivotSortState: [{ dimensions: [{ dimensionKey: 'item', isPivotCorner: true }, + { dimensionKey: 'category', isPivotCorner: true }], order: 'desc' }] + }); + }, + async verify(page) { + // 角头排序路径和指标值都应被实际透视表接收并绘制。 + await page.evaluate(() => { + const table = window.__visualTable; + const state = table.pivotSortState?.[0]; + if (state?.order !== 'desc' || state.dimensions?.length !== 2 || + state.dimensions.some(d => !d.isPivotCorner) || table.rowCount < 3) + throw new Error(`角头排序状态缺失:${JSON.stringify(table.pivotSortState)}`); + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellOriginValue(col, row)); + if (!values.includes(100) || !values.includes(250)) throw new Error('角头排序的指标数据缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/corner-without-rows.mjs b/packages/vtable/__tests__/visual/cases/pivot/corner-without-rows.mjs new file mode 100644 index 0000000000..efa7888478 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/corner-without-rows.mjs @@ -0,0 +1,34 @@ +/** + * BugServer case IDs: 66b32ebbd62b6300ba4721d8 + * 验证目的:没有行维度与指标时,显式 columnTree 仍显示列头和强制角头。 + * 改写:删除未触发的菜单和粘贴配置,保留无行无指标的结构边界。 + */ +export default { + mount(container) { + // 三个固定列树节点足以验证强制角头与列头布局。 + return new window.VTable.PivotTable(container, { + defaultColWidth: 200, + columnTree: [ + { dimensionKey: 'Category', value: 'Office Supplies' }, + { dimensionKey: 'Category', value: 'Technology' }, + { dimensionKey: 'Category', value: 'Furniture' } + ], + columns: [{ dimensionKey: 'City', title: 'City', + headerStyle: { textStick: true, bgColor: '#356b9c', color: '#00ffff' } }], + indicatorsAsCol: true, + corner: { forceShowHeader: true, titleOnDimension: 'column', + headerStyle: { bgColor: '#356b9c', color: '#00ffff' } } + }); + }, + async verify(page) { + // 三个类别必须真的成为表格头部,而不是仅存在配置中。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellValue(col, row)); + if (!values.includes('Technology') || !values.includes('Furniture')) + throw new Error('空行维度列头缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/custom-merge-subtotal.mjs b/packages/vtable/__tests__/visual/cases/pivot/custom-merge-subtotal.mjs new file mode 100644 index 0000000000..d94cc2fe99 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/custom-merge-subtotal.mjs @@ -0,0 +1,38 @@ +/** + * BugServer case IDs: 6785dfb8d2dacd00b3443ebf + * 验证目的:行头自定义合并两列时,小计和总计单元格保留原始聚合值。 + * 改写:将地区名称改为通用组,保留三维行头、跳过合计的合并回调与行小计。 + */ +export default { + mount(container) { + // 普通行合并第 1-2 列,小计/总计返回 undefined 交回透视表处理。 + return new window.VTable.PivotTable(container, { + records: [{ area: 'North', region: 'N1', city: 'N1', amount: 1 }, + { area: 'North', region: 'N2', city: 'N2', amount: 2 }, + { area: 'South', region: 'S1', city: 'S1', amount: 3 }], + rows: [{ dimensionKey: 'area', title: 'Area' }, { dimensionKey: 'region', title: 'Region' }, + { dimensionKey: 'city', title: 'City' }], + indicators: [{ indicatorKey: 'amount', title: 'Amount', width: 100 }], + dataConfig: { totals: { row: { showGrandTotals: true, showSubTotals: true, + subTotalsDimensions: ['area'], grandTotalLabel: 'Total', subTotalLabel: 'Subtotal' } } }, + customMergeCell: (col, row, table) => { + if (col <= 0 || col >= 3 || row <= 0) return undefined; + const value = table.getCellOriginValue(col, row); + if (value === 'Subtotal' || value === 'Total') return undefined; + return { text: value, range: { start: { col: 1, row }, end: { col: 2, row } }, + style: { textAlign: 'center' } }; + }, + keyboardOptions: { moveEditCellOnArrowKeys: true, copySelected: true, pasteValueToCell: true } + }); + }, + async verify(page) { + // North 小计 3 与总计 6 必须保留,且至少有一个普通行头合并。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellOriginValue(col, row)); + if (!values.includes('Subtotal') || !values.includes(6)) throw new Error('自定义合并丢失合计'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/custom-sort-click-colors.mjs b/packages/vtable/__tests__/visual/cases/pivot/custom-sort-click-colors.mjs new file mode 100644 index 0000000000..f54c590725 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/custom-sort-click-colors.mjs @@ -0,0 +1,66 @@ +/** + * BugServer case IDs: 6581698ceab07e0091708bab + * 验证目的:透视行维度自定义比较器点击排序,同时两个指标采用独立背景映射。 + * 改写:地域与销售示例替换为固定 A/B/C 分类和整数,保留 sortFunc、双指标映射及点击动作。 + */ +export default { + mount(container) { + // 三个城市值故意不按名称排列,使初始排序和点击后的变化可观察。 + return new window.VTable.PivotTable(container, { + rows: [{ dimensionKey: 'region', title: 'Region' }, { dimensionKey: 'city', title: 'City', sort: true }], + columns: ['category', 'sub'], indicators: ['amount', 'count'], indicatorsAsCol: false, + enableDataAnalysis: true, corner: { titleOnDimension: 'row' }, + dataConfig: { + sortRules: [{ sortField: 'city', sortType: window.VTable.TYPES.SortType.ASC, + sortFunc: (a, b, type) => String(a).localeCompare(String(b)) * (type === 'DESC' ? -1 : 1) }], + mappingRules: [ + { bgColor: { indicatorKey: 'amount', mapping: ({ value }) => value >= 20 ? '#ffd6d6' : '#ffffff' } }, + { bgColor: { indicatorKey: 'count', mapping: ({ value }) => value >= 2 ? '#d6f5df' : '#ffffff' } } + ] + }, + records: [ + { region: 'R', city: 'B', category: 'X', sub: 'Y', amount: 20, count: 2 }, + { region: 'R', city: 'C', category: 'X', sub: 'Y', amount: 30, count: 3 }, + { region: 'R', city: 'A', category: 'X', sub: 'Y', amount: 10, count: 1 } + ] + }); + }, + async exercise(page) { + // 查找真实排序图标并点击一次,避免直接改状态掩盖交互问题。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + const labels = Array.from({ length: table.rowCount }, (_, row) => table.getCellValue(1, row)); + window.__initialCityOrder = ['A', 'B', 'C'].map(value => labels.indexOf(value)).join(','); + const host = document.getElementById('table').getBoundingClientRect(); + for (let row = 0; row < table.rowCount; row++) { + for (let col = 0; col < table.colCount; col++) { + const stack = [table.scenegraph.getCell(col, row)]; + while (stack.length) { + const mark = stack.pop(); + if (mark?.attribute?.funcType === 'sort') { + const b = mark.globalAABBBounds; + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + } + stack.push(...(mark?.children ?? [])); + } + } + } + throw new Error('透视城市排序图标不存在'); + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 点击后城市行顺序必须变化,且两个指标值仍可读取,截图验证独立着色。 + await page.evaluate(() => { + const table = window.__visualTable; + const labels = Array.from({ length: table.rowCount }, (_, row) => table.getCellValue(1, row)); + const positions = ['A', 'B', 'C'].map(value => labels.indexOf(value)); + if (positions.some(index => index < 0) || positions.join(',') === window.__initialCityOrder) + throw new Error(`透视自定义排序未改变城市顺序:${labels}`); + if (!Array.from({ length: table.rowCount }, (_, row) => + Array.from({ length: table.colCount }, (_, col) => table.getCellValue(col, row))) + .flat().some(value => value === 30)) + throw new Error('排序后指标值丢失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/custom-total-data-tree.mjs b/packages/vtable/__tests__/visual/cases/pivot/custom-total-data-tree.mjs new file mode 100644 index 0000000000..36a441f2c8 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/custom-total-data-tree.mjs @@ -0,0 +1,56 @@ +/** + * BugServer case IDs: 652e02311dd15a4ab522f753 + * 验证目的:带显式行列汇总记录的树形透视表仍能展开并显示汇总值。 + * 改写:原地区商品明细换成固定 A/B 分类,保留缺维度汇总行和树展开动作。 + */ +export default { + mount(container) { + // 缺失部分维度的记录代表外部传入的小计和总计。 + container.style.height = '400px'; + return new window.VTable.PivotTable(container, { + rows: ['group', 'item'], columns: ['type', 'subtype'], + indicators: ['amount', 'count'], indicatorsAsCol: true, + enableDataAnalysis: true, rowHierarchyType: 'tree', rowExpandLevel: 1, + dataConfig: { totals: { + row: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['group', 'item'] }, + column: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['type'] } + } }, + records: [ + { amount: 111, count: 10 }, + { group: 'A', amount: 55, count: 5 }, + { group: 'A', type: 'X', amount: 30, count: 3 }, + { type: 'X', subtype: 'X1', amount: 45, count: 4 }, + { group: 'A', item: 'A1', type: 'X', subtype: 'X1', amount: 12, count: 1 }, + { group: 'A', item: 'A2', type: 'X', subtype: 'X1', amount: 18, count: 2 }, + { group: 'B', item: 'B1', type: 'Y', subtype: 'Y1', amount: 25, count: 3 } + ], widthMode: 'standard' + }); + }, + async exercise(page) { + // 定位 A 行的树图标并展开子项。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + const row = Array.from({ length: table.rowCount }, (_, i) => i) + .find(i => table.getCellValue(0, i) === 'A'); + if (row === undefined || table.getHierarchyState(0, row) !== 'collapse') + throw new Error('A 组不是可展开树节点'); + const b = table.getCellRect(0, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + b.x1 + 23, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // A 组展开且显式总计 111 进入可见透视值。 + await page.evaluate(() => { + const table = window.__visualTable; + const row = Array.from({ length: table.rowCount }, (_, i) => i) + .find(i => table.getCellValue(0, i) === 'A'); + const values = []; + for (let y = 0; y < table.rowCount; y++) + for (let x = 0; x < table.colCount; x++) values.push(table.getCellOriginValue(x, y)); + if (row === undefined || table.getHierarchyState(0, row) !== 'expand' || !values.includes(111)) + throw new Error('树形小计展开或显式总计缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/empty-string-dimension.mjs b/packages/vtable/__tests__/visual/cases/pivot/empty-string-dimension.mjs new file mode 100644 index 0000000000..a8b28d05c0 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/empty-string-dimension.mjs @@ -0,0 +1,38 @@ +/** + * BugServer case IDs: 6971d76ed74ba0005dc5e9b6 + * 验证目的:空字符串列维度应与普通列维度并存,并显示 NONE 聚合值。 + * 改写:来源中的不透明字段与人口数据换成固定代号和数值字符串。 + */ +export default { + mount(container) { + // 同一期同时存在空维度和 A 维度,另附缺行维度的汇总记录。 + return new window.VTable.PivotTable(container, { + rows: [{ dimensionKey: 'period', title: 'Period', width: 120 }], + columns: [{ dimensionKey: 'segment', title: 'Segment', width: 130 }], + indicators: [{ indicatorKey: 'amount', title: 'Amount', width: 120 }], + records: [ + { period: 'P1', segment: '', amount: '10' }, + { period: 'P1', segment: 'A', amount: '20' }, + { period: 'P2', segment: '', amount: '30' }, + { period: 'P2', segment: 'A', amount: '40' }, + { segment: '', amount: '40' }, + { segment: 'A', amount: '60' } + ], + dataConfig: { aggregationRules: [{ indicatorKey: 'amount', field: 'amount', aggregationType: 'NONE' }], + totals: { row: { showGrandTotals: false }, column: { showGrandTotals: false } } }, + corner: { titleOnDimension: 'all' }, widthMode: 'standard' + }); + }, + async verify(page) { + // 空维度不能吞掉两期数值,也不能与 A 列合并。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(String(table.getCellOriginValue(col, row))); + if (table.colCount < 3 || !values.includes('10') || !values.includes('30') || + !values.includes('20') || !values.includes('40')) + throw new Error(`空字符串维度被合并或丢失:${JSON.stringify(values)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/indicator-hide-frozen.mjs b/packages/vtable/__tests__/visual/cases/pivot/indicator-hide-frozen.mjs new file mode 100644 index 0000000000..4ca9fa2169 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/indicator-hide-frozen.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 676a70de0982bf00b14e2316, 654311df0dc4c5635dc0d9ee + * 验证目的:按列维度隐藏指定指标,同时保留右侧列和底部行冻结。 + * 改写:把来源的多级地区和商品数据缩为两个匿名维度值。 + */ +export default { + mount(container) { + // B 列的 count 被隐藏,A 列的两个指标仍显示。 + window.__pivotHidePaths = []; + return new window.VTable.PivotTable(container, { + rows: ['group'], columns: ['category'], indicatorsAsCol: true, + indicators: [ + { indicatorKey: 'amount', title: 'Amount' }, + { indicatorKey: 'count', title: 'Count', hide({ dimensionPaths }) { + const values = dimensionPaths.map(path => path.value); + window.__pivotHidePaths.push(values); + return values.includes('B'); + } } + ], + records: [ + { group: 'G1', category: 'A', amount: 11, count: 17 }, + { group: 'G1', category: 'B', amount: 23, count: 29 }, + { group: 'G2', category: 'A', amount: 31, count: 37 }, + { group: 'G2', category: 'B', amount: 41, count: 43 } + ], + rightFrozenColCount: 1, bottomFrozenRowCount: 1, + defaultColWidth: 135, defaultHeaderColWidth: 90, widthMode: 'standard' + }); + }, + async verify(page) { + // 唯一的 B/count 数值不可见,其余指标与冻结数量必须保留。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellOriginValue(col, row)); + if (![11, 17, 23, 31, 37, 41].every(value => values.includes(value)) || + values.includes(29) || values.includes(43)) + throw new Error('透视指标隐藏结果错误'); + if (!window.__pivotHidePaths.some(paths => paths.includes('B')) || + table.rightFrozenColCount !== 1 || table.bottomFrozenRowCount !== 1) + throw new Error('隐藏回调或冻结配置未生效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/indicator-sort-rule.mjs b/packages/vtable/__tests__/visual/cases/pivot/indicator-sort-rule.mjs new file mode 100644 index 0000000000..168fb4933a --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/indicator-sort-rule.mjs @@ -0,0 +1,30 @@ +/** + * BugServer case IDs: 64ed9123d4858ce3fbd85ad9 + * 验证目的:透视表按指定指标和列维度路径对行维度降序排序,并映射背景色。 + * 改写:销售明细换成固定 A/B/C 数值,保留 sortByIndicator、query 与 mappingRules。 + */ +export default { + mount(container) { + // 三个城市的 amount 分别为 10、30、20,降序应先 C 再 B 再 A。 + return new window.VTable.PivotTable(container, { + rows: ['region', 'city'], columns: ['kind', 'variant'], indicators: ['amount', 'count'], + enableDataAnalysis: true, indicatorsAsCol: false, + dataConfig: { sortRules: [{ sortField: 'city', sortByIndicator: 'amount', + sortType: window.VTable.TYPES.SortType.DESC, query: ['X', 'X1'] }], + mappingRules: [{ bgColor: { indicatorKey: 'amount', mapping: ({ value }) => value >= 20 ? '#ffc0c0' : '#ffffff' } }] }, + records: [{ region: 'R', city: 'A', kind: 'X', variant: 'X1', amount: 10, count: 1 }, + { region: 'R', city: 'C', kind: 'X', variant: 'X1', amount: 30, count: 3 }, + { region: 'R', city: 'B', kind: 'X', variant: 'X1', amount: 20, count: 2 }] + }); + }, + async verify(page) { + // 扫描行标题顺序,避免只确认规则被保存却未实际排序。 + await page.evaluate(() => { + const table = window.__visualTable; + const labels = Array.from({ length: table.rowCount }, (_, row) => table.getCellValue(1, row)); + const positions = ['C', 'B', 'A'].map(value => labels.indexOf(value)); + if (positions.some(index => index < 0) || !(positions[0] < positions[1] && positions[1] < positions[2])) + throw new Error(`透视指标排序错误:${labels}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/indicator-sort-subtotal.mjs b/packages/vtable/__tests__/visual/cases/pivot/indicator-sort-subtotal.mjs new file mode 100644 index 0000000000..7d2f56f101 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/indicator-sort-subtotal.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 65be028bce3320008eecf685 + * 验证目的:按指定指标和列路径排序行维度,同时显示该维度的小计。 + * 改写:维度标签改为通用名称,保留两级行列、两指标、query、sortRules 与 subTotalsDimensions。 + */ +export default { + mount(container) { + // 同一查询路径中给多个区域不同数值,以展示排序与小计。 + return new window.VTable.PivotTable(container, { + rows: ['region', 'site'], columns: ['category', 'variant'], + indicators: ['sales', 'number'], enableDataAnalysis: true, + indicatorsAsCol: true, indicatorTitle: 'Metric', + dataConfig: { sortRules: [{ sortField: 'region', sortByIndicator: 'sales', + sortType: window.VTable.TYPES.SortType.ASC, query: ['Type A', 'Variant X'] }], + totals: { row: { subTotalsDimensions: ['region'] } } }, + corner: { titleOnDimension: 'row' }, + records: [{ sales: 1295, number: 43, region: 'Region A', site: 'Site 1', category: 'Type A', variant: 'Variant X' }, + { sales: 995, number: 53, region: 'Region A', site: 'Site 2', category: 'Type A', variant: 'Variant X' }, + { sales: 1045, number: 33, region: 'Region A', site: 'Site 3', category: 'Type A', variant: 'Variant X' }, + { sales: 792, number: 14, region: 'Region B', site: 'Site 4', category: 'Type A', variant: 'Variant X' }, + { sales: 800, number: 24, region: 'Region C', site: 'Site 5', category: 'Type A', variant: 'Variant X' }], + widthMode: 'autoWidth' + }); + }, + async verify(page) { + // 三个区域和指标小计应使透视行数大于原始五条记录。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount < 8 || table.colCount < 3 || !table.options.dataConfig.totals.row.subTotalsDimensions.includes('region')) + throw new Error('指标排序与小计场景未建立'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/initial-column-sort-state.mjs b/packages/vtable/__tests__/visual/cases/pivot/initial-column-sort-state.mjs new file mode 100644 index 0000000000..1de71a6bce --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/initial-column-sort-state.mjs @@ -0,0 +1,45 @@ +/** + * BugServer case IDs: 64bdfdee5134109a76ccddd5 + * 验证目的:透视列树指定分支的初始 pivotSortState 与自动填宽共同绘制。 + * 改写:保留非角头两级列路径、降序状态及 autoFillWidth,使用固定通用维度与记录。 + */ +export default { + mount(container) { + // 两级列树包含排序目标 Second 分支,并由固定记录提供可见指标值。 + return new window.VTable.PivotTable(container, { + rows: [{ dimensionKey: 'category', title: 'Category' }], + columns: [{ dimensionKey: 'region', title: 'Region' }, + { dimensionKey: 'method', title: 'Method', showSort: true }], + indicators: [{ indicatorKey: 'amount', title: 'Amount', showSort: true }], + columnTree: [{ dimensionKey: 'region', value: 'North', children: [ + { dimensionKey: 'method', value: 'First', children: [{ indicatorKey: 'amount' }] }, + { dimensionKey: 'method', value: 'Second', children: [{ indicatorKey: 'amount' }] } + ] }], + records: [ + { category: 'A', region: 'North', method: 'First', amount: 10 }, + { category: 'A', region: 'North', method: 'Second', amount: 20 }, + { category: 'B', region: 'North', method: 'First', amount: 30 }, + { category: 'B', region: 'North', method: 'Second', amount: 40 } + ], + pivotSortState: [{ dimensions: [ + { dimensionKey: 'region', value: 'North', isPivotCorner: false }, + { dimensionKey: 'method', value: 'Second', isPivotCorner: false } + ], order: 'desc' }], + enableDataAnalysis: true, autoFillWidth: true, widthMode: 'standard', + defaultHeaderRowHeight: 40, defaultHeaderColWidth: 90 + }); + }, + async verify(page) { + // 初始排序路径、列树和数据值都应被透视表接受。 + await page.evaluate(() => { + const table = window.__visualTable; + const state = table.pivotSortState?.[0]; + if (state?.order !== 'desc' || state.dimensions?.[1]?.value !== 'Second' || table.colCount < 3) + throw new Error(`初始透视排序状态缺失:${JSON.stringify(table.pivotSortState)}`); + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellOriginValue(col, row)); + if (!values.includes(20) || !values.includes(40)) throw new Error('列树排序目标的指标数据缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/no-indicators.mjs b/packages/vtable/__tests__/visual/cases/pivot/no-indicators.mjs new file mode 100644 index 0000000000..1257c631ec --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/no-indicators.mjs @@ -0,0 +1,28 @@ +/** + * BugServer case IDs: 647452cf5d221c008666ee17 + * 验证目的:无指标透视表按显式行列树绘制链接型维度表头。 + * 改写:来源的大型主题与超长分类列表缩为固定公开样本,保留无指标、树和链接表头条件。 + */ +export default { + mount(container) { + // 显式树节点与零指标一起覆盖只有维度的透视边界。 + container.style.width = '800px'; + container.style.height = '400px'; + return new window.VTable.PivotTable(container, { + rows: [{ dimensionKey: 'group', title: 'Group', width: 'auto', headerType: 'link', linkDetect: true, linkJump: false }], + columns: [{ dimensionKey: 'method', title: 'Method', headerType: 'link', linkDetect: true, linkJump: false }], + rowTree: ['Alpha', 'Beta', 'Gamma', 'Delta'].map(value => ({ dimensionKey: 'group', value })), + columnTree: ['Standard', 'Express', 'Same day'].map(value => ({ dimensionKey: 'method', value })), + hideIndicatorName: true, allowFrozenColCount: 2, columnResizeType: 'indicator', + corner: { titleOnDimension: 'row', headerStyle: { textStick: true } } + }); + }, + async verify(page) { + // 行列树必须生成有效区域;视觉比较负责验证表头的绘制。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount < 4 || table.colCount < 3 || !Number.isFinite(table.getCellRect(1, 1)?.bounds?.x1)) + throw new Error('无指标透视表未生成预期的行列结构'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/resize-indicator-group.mjs b/packages/vtable/__tests__/visual/cases/pivot/resize-indicator-group.mjs new file mode 100644 index 0000000000..78e582bf53 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/resize-indicator-group.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 662e20c6d4511700f6885626 + * 验证目的:indicatorGroup 拖动一个指标行边界时按比例调整同组的两个指标行。 + * 改写:来源的地区商品数据改为三个匿名行维度组合。 + */ +export default { + mount(container) { + // 每个条目有金额和数量两个指标行,拖动组边界应影响两行。 + return new window.VTable.PivotTable(container, { + rows: ['group', 'item'], columns: ['period'], + indicators: ['amount', 'count'], indicatorsAsCol: false, + records: [ + { group: 'A', item: 'A1', period: 'First', amount: 10, count: 1 }, + { group: 'A', item: 'A2', period: 'First', amount: 20, count: 2 }, + { group: 'B', item: 'B1', period: 'Second', amount: 30, count: 3 } + ], + defaultRowHeight: 32, resize: { rowResizeMode: 'all', rowResizeType: 'indicatorGroup' } + }); + }, + async exercise(page) { + // 在第一个条目的指标行下边界拖动,并记录同组两行原始高度。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + const row = table.columnHeaderLevelCount; + const b = table.getCellRect(table.rowHeaderLevelCount - 1, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + window.__indicatorGroupStartRow = row; + window.__indicatorGroupBefore = [table.getRowHeight(row), table.getRowHeight(row + 1)]; + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + b.y2 + 1 }; + }); + await page.mouse.move(point.x, point.y); + await page.mouse.down(); + await page.mouse.move(point.x, point.y + 36, { steps: 10 }); + await page.mouse.up(); + }, + async verify(page) { + // 同一指标组的两行都应增高,区别于单行调整。 + await page.evaluate(() => { + const table = window.__visualTable; + const row = window.__indicatorGroupStartRow; + const heights = [table.getRowHeight(row), table.getRowHeight(row + 1)]; + if (heights.some((height, index) => height < window.__indicatorGroupBefore[index] + 5)) + throw new Error(`指标组行高未同时改变:${JSON.stringify(heights)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/resize-indicator-row.mjs b/packages/vtable/__tests__/visual/cases/pivot/resize-indicator-row.mjs new file mode 100644 index 0000000000..eb494ce5ae --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/resize-indicator-row.mjs @@ -0,0 +1,44 @@ +/** + * BugServer case IDs: 662e204dd4511700f6885624 + * 验证目的:指标放在行上的透视表可拖动行头边界调整指标行高。 + * 改写:维度和数值换成固定匿名示例,保留 indicatorsAsCol=false 与拖拽。 + */ +export default { + mount(container) { + // 两个指标在每个行维度下形成可调整的指标行。 + return new window.VTable.PivotTable(container, { + rows: ['group', 'item'], columns: ['region'], + indicators: ['amount', 'count'], indicatorsAsCol: false, + records: [ + { group: 'A', item: 'A1', region: 'East', amount: 10, count: 1 }, + { group: 'A', item: 'A2', region: 'West', amount: 20, count: 2 }, + { group: 'B', item: 'B1', region: 'East', amount: 30, count: 3 } + ], defaultRowHeight: 32, widthMode: 'standard', resize: { rowResizeMode: 'all' } + }); + }, + async exercise(page) { + // 在首个指标行的左侧行头边界向下拖动。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + const row = table.columnHeaderLevelCount; + const b = table.getCellRect(table.rowHeaderLevelCount - 1, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + window.__pivotResizeRow = row; + window.__pivotResizeCol = table.rowHeaderLevelCount - 1; + window.__pivotHeightBefore = b.y2 - b.y1; + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + b.y2 + 1 }; + }); + await page.mouse.move(point.x, point.y); + await page.mouse.down(); + await page.mouse.move(point.x, point.y + 30, { steps: 10 }); + await page.mouse.up(); + }, + async verify(page) { + // 指标行相对矩形应比初始配置高至少 15px。 + await page.evaluate(() => { + const b = window.__visualTable.getCellRect(window.__pivotResizeCol, window.__pivotResizeRow).bounds; + if (b.y2 - b.y1 < window.__pivotHeightBefore + 15) + throw new Error('透视指标行高未调整'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/tree-zero-expand.mjs b/packages/vtable/__tests__/visual/cases/pivot/tree-zero-expand.mjs new file mode 100644 index 0000000000..d620c4f008 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/tree-zero-expand.mjs @@ -0,0 +1,51 @@ +/** + * BugServer case IDs: 66deb9600846a700faef5e60 + * 验证目的:值为数字 0 的透视树父节点仍能展开子维度。 + * 改写:将来源远程数据替换为固定公开记录,保留零值、双层行维度及录制展开动作。 + */ +export default { + mount(container) { + // 零值类别和普通类别并存,用于识别把 0 错当空值的层级处理。 + container.style.width = '800px'; + container.style.height = '400px'; + return new window.VTable.PivotTable(container, { + records: [ + { category: 0, item: 'Zero A', region: 'East', quantity: 2 }, + { category: 0, item: 'Zero B', region: 'West', quantity: 3 }, + { category: 'Other', item: 'Other A', region: 'East', quantity: 4 }, + { category: 'Other', item: 'Other B', region: 'West', quantity: 5 } + ], + rows: [{ dimensionKey: 'category', title: 'Category', width: 'auto' }, + { dimensionKey: 'item', title: 'Item', width: 'auto' }], + columns: [{ dimensionKey: 'region', title: 'Region', width: 'auto' }], + indicators: [{ indicatorKey: 'quantity', title: 'Quantity', width: 'auto' }], + rowHierarchyType: 'tree', rowExpandLevel: 1, widthMode: 'standard' + }); + }, + async exercise(page) { + // 在表头以下定位数字 0 所在行,再点击该行左侧层级图标。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + const row = Array.from({ length: table.rowCount }, (_, index) => index) + .find(index => table.getCellValue(0, index) === 0); + if (row === undefined || table.getHierarchyState(0, row) !== 'collapse') + throw new Error('未找到可展开的零值父节点'); + const b = table.getCellRect(0, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + b.x1 + 23, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 数字 0 父节点必须转为展开并显示两个子项。 + await page.waitForFunction(() => { + const table = window.__visualTable; + const row = Array.from({ length: table.rowCount }, (_, index) => index) + .find(index => table.getCellValue(0, index) === 0); + return row !== undefined && table.getHierarchyState(0, row) === 'expand'; + }); + await page.evaluate(() => { + if (window.__visualTable.rowCount < 5) throw new Error('零值类别的子项未进入可见行'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/update-filter.mjs b/packages/vtable/__tests__/visual/cases/pivot/update-filter.mjs new file mode 100644 index 0000000000..cbe330b010 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/update-filter.mjs @@ -0,0 +1,32 @@ +/** + * BugServer case IDs: 66da9cdd0846a700faef5def + * 验证目的:透视表初始化过滤规则后由 updateFilterRules 替换条件并重新聚合。 + * 改写:记录换成固定交叉分组,保留先过滤一组再改为另一组的顺序。 + */ +export default { + mount(container) { + // 最终规则剔除 R1/A,允许 R2/A 重新进入汇总。 + const table = new window.VTable.PivotTable(container, { + rows: ['region', 'item'], columns: ['kind', 'variant'], indicators: ['amount', 'count'], + enableDataAnalysis: true, indicatorsAsCol: true, + dataConfig: { filterRules: [{ filterFunc: record => !(record.region === 'R2' && record.kind === 'A') }], + totals: { row: { subTotalsDimensions: ['region'] } } }, + records: [{ region: 'R1', item: 'I1', kind: 'A', variant: 'A1', amount: 10, count: 1 }, + { region: 'R1', item: 'I2', kind: 'B', variant: 'B1', amount: 20, count: 2 }, + { region: 'R2', item: 'I1', kind: 'A', variant: 'A1', amount: 30, count: 3 }, + { region: 'R2', item: 'I2', kind: 'B', variant: 'B1', amount: 40, count: 4 }] + }); + table.updateFilterRules([{ filterFunc: record => !(record.region === 'R1' && record.kind === 'A') }]); + return table; + }, + async verify(page) { + // 更新后的透视值应包含 R2/A 的 30,且排除 R1/A 的 10。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellOriginValue(col, row)); + if (!values.includes(30) || values.includes(10)) throw new Error('透视过滤规则未替换'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/update-option-row-header-width.mjs b/packages/vtable/__tests__/visual/cases/pivot/update-option-row-header-width.mjs new file mode 100644 index 0000000000..92a4cf2fe6 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/update-option-row-header-width.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 6a95269bcf3b32005d0b9b74 + * 验证目的:grid-tree 透视表 updateOption 后,四级行表头列宽不回退到默认值。 + * 改写:压缩重复记录和状态标签,保留相同配置更新及列宽前后比较。 + */ +export default { + mount(container) { + // 四级行维度和行列合计共同触发行头宽度重算路径。 + container.style.height = '500px'; + const option = { + records: [{ year: '2022', region: 'East', segment: 'Consumer', mode: 'First', category: 'A', item: 'A1', sales: 891, profit: 112 }, + { year: '2022', region: 'East', segment: 'Corporate', mode: 'Second', category: 'A', item: 'A2', sales: 496, profit: 48 }, + { year: '2023', region: 'West', segment: 'Consumer', mode: 'Standard', category: 'B', item: 'B1', sales: 895, profit: 152 }], + rows: ['year', 'region', 'segment', 'mode'], columns: ['category', 'item'], + indicators: ['sales', 'profit'], enableDataAnalysis: true, indicatorTitle: 'Indicators', + rowExpandLevel: Infinity, defaultColWidth: 120, defaultRowHeight: 32, + widthMode: 'standard', heightMode: 'standard', + rowHierarchyType: 'grid-tree', columnHierarchyType: 'grid-tree', + corner: { titleOnDimension: 'column' }, + dataConfig: { totals: { row: { showGrandTotals: true, showSubTotals: true, + subTotalsDimensions: ['year', 'region', 'segment'], grandTotalLabel: 'Total', subTotalLabel: 'Subtotal' }, + column: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['category'], + grandTotalLabel: 'Total', subTotalLabel: 'Subtotal' } } } + }; + const table = new window.VTable.PivotTable(container, option); + window.__beforeWidths = [0, 1, 2, 3].map(col => table.getColWidth(col)); + table.updateOption(option, { clearColWidthCache: false }); + return table; + }, + async verify(page) { + // 更新后的行头宽度应与更新前一致且不会全回退到 80。 + await page.evaluate(() => { + const table = window.__visualTable; + const after = [0, 1, 2, 3].map(col => table.getColWidth(col)); + if (after.some((width, i) => Math.abs(width - window.__beforeWidths[i]) > 1) || + after.every(width => Math.round(width) === 80)) + throw new Error(`行头宽度更新异常: ${window.__beforeWidths} => ${after}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/update-pivot-sort-state-click.mjs b/packages/vtable/__tests__/visual/cases/pivot/update-pivot-sort-state-click.mjs new file mode 100644 index 0000000000..34ed9d2299 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/update-pivot-sort-state-click.mjs @@ -0,0 +1,73 @@ +/** + * BugServer case IDs: 6708f3b739265200a62f816c + * 验证目的:点击透视表排序图标后,通过 pivot_sort_click 更新 pivotSortState。 + * 改写:日期与指标名称换成固定通用字段,保留虚拟列树与事件回写路径。 + */ +export default { + mount(container) { + // 排序图标出现在两个行维度及部分指标中,事件回调统一写入降序状态。 + const table = new window.VTable.PivotTable(container, { + records: [ + { day: '2024-08-01', week: 'Thu', quantityA: 1, amountA: 4, quantityB: 2, amountB: 2 }, + { day: '2024-08-03', week: 'Sat', quantityA: 2, amountA: 3, quantityB: 2, amountB: 2 }, + { day: '2024-08-07', week: 'Wed', quantityA: 3, amountA: 2, quantityB: 2, amountB: 2 }, + { day: '2024-08-09', week: 'Fri', quantityA: 4, amountA: 1, quantityB: 2, amountB: 2 } + ], + rows: [{ dimensionKey: 'day', title: 'Day', showSort: true, width: 120 }, + { dimensionKey: 'week', title: 'Week', showSort: true, width: 90 }], + columns: [{ dimensionKey: 'group', title: 'Group' }], + columnTree: [ + { dimensionKey: 'group', value: 'A', virtual: true, + children: [{ indicatorKey: 'quantityA' }, { indicatorKey: 'amountA' }] }, + { dimensionKey: 'group', value: 'B', virtual: true, + children: [{ indicatorKey: 'quantityB' }, { indicatorKey: 'amountB' }] } + ], + indicators: [{ indicatorKey: 'quantityA', title: 'Quantity A', showSort: true }, + { indicatorKey: 'amountA', title: 'Amount A', showSort: true }, + { indicatorKey: 'quantityB', title: 'Quantity B' }, + { indicatorKey: 'amountB', title: 'Amount B' }], + corner: { titleOnDimension: 'all' }, widthMode: 'standard' + }); + window.__pivotSortClicks = 0; + table.on('pivot_sort_click', event => { + // 与来源相同,外部业务处理后把点击维度写回专用排序状态接口。 + window.__pivotSortClicks++; + table.updatePivotSortState([{ dimensions: event.dimensionInfo, order: 'desc' }]); + table.renderWithRecreateCells(); + }); + return table; + }, + async exercise(page) { + // 查找首个透视排序图标并真正点击,避免直接设置状态绕过事件。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + for (let row = 0; row < table.rowCount; row++) { + for (let col = 0; col < table.colCount; col++) { + const stack = [table.scenegraph.getCell(col, row)]; + while (stack.length) { + const mark = stack.pop(); + if (mark?.attribute?.funcType === 'sort') { + const bounds = mark.globalAABBBounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (bounds.x1 + bounds.x2) / 2, + y: host.y + (bounds.y1 + bounds.y2) / 2 }; + } + stack.push(...(mark?.children ?? [])); + } + } + } + throw new Error('透视排序图标缺失'); + }); + await page.mouse.click(point.x, point.y); + await page.waitForFunction(() => window.__pivotSortClicks > 0); + }, + async verify(page) { + // 事件发生后专用 pivotSortState 必须保存一条降序维度路径。 + await page.evaluate(() => { + const table = window.__visualTable; + if (window.__pivotSortClicks < 1 || table.pivotSortState?.[0]?.order !== 'desc' || + !table.pivotSortState[0].dimensions?.length) + throw new Error(`透视排序状态未回写:${JSON.stringify(table.pivotSortState)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/update-sort-rules-after-click.mjs b/packages/vtable/__tests__/visual/cases/pivot/update-sort-rules-after-click.mjs new file mode 100644 index 0000000000..fc9b48b118 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/update-sort-rules-after-click.mjs @@ -0,0 +1,74 @@ +/** + * BugServer case IDs: 65be113cce3320008eecf686 + * 验证目的:透视排序图标点击后,updateSortRules 同时替换行维度和列维度规则。 + * 改写:地域和品类均换成匿名维度,保留双指标、层级、总计及规则更新。 + */ +export default { + mount(container) { + // 初始按 Amount 降序排列区域;三条记录使更新为升序后顺序可见。 + const table = new window.VTable.PivotTable(container, { + rows: [{ dimensionKey: 'region', title: 'Region', sort: true }, + { dimensionKey: 'city', title: 'City', sort: true }], + columns: ['category', { dimensionKey: 'variant', title: 'Variant', sort: true }], + indicators: ['amount', 'count'], indicatorsAsCol: true, + enableDataAnalysis: true, corner: { titleOnDimension: 'all' }, + dataConfig: { + sortRules: [{ sortField: 'region', sortByIndicator: 'amount', + sortType: window.VTable.TYPES.SortType.DESC, query: ['X', 'X1'] }], + totals: { row: { subTotalsDimensions: ['region'] } } + }, + records: [ + { region: 'R1', city: 'A', category: 'X', variant: 'X1', amount: 30, count: 3 }, + { region: 'R1', city: 'B', category: 'X', variant: 'X1', amount: 20, count: 2 }, + { region: 'R2', city: 'C', category: 'X', variant: 'X1', amount: 5, count: 1 }, + { region: 'R1', city: 'A', category: 'X', variant: 'X2', amount: 40, count: 4 }, + { region: 'R2', city: 'C', category: 'X', variant: 'X2', amount: 10, count: 2 } + ], widthMode: 'autoWidth' + }); + window.__pivotSortClicks = 0; + table.on('pivot_sort_click', () => { window.__pivotSortClicks++; }); + return table; + }, + async exercise(page) { + // 点击真实透视排序图标后再通过公开 API 替换两条规则。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + const host = document.getElementById('table').getBoundingClientRect(); + for (let row = 0; row < table.rowCount; row++) { + for (let col = 0; col < table.colCount; col++) { + const stack = [table.scenegraph.getCell(col, row)]; + while (stack.length) { + const mark = stack.pop(); + if (mark?.attribute?.funcType === 'sort') { + const b = mark.globalAABBBounds; + return { x: host.x + (b.x1 + b.x2) / 2, + y: host.y + (b.y1 + b.y2) / 2 }; + } + stack.push(...(mark?.children ?? [])); + } + } + } + throw new Error('透视排序图标缺失'); + }); + await page.mouse.click(point.x, point.y); + await page.evaluate(() => { + window.__visualTable.updateSortRules([ + { sortField: 'region', sortByIndicator: 'amount', + sortType: window.VTable.TYPES.SortType.ASC, query: ['X', 'X1'] }, + { sortField: 'variant', sortByIndicator: 'amount', + sortType: window.VTable.TYPES.SortType.DESC, query: ['R1', 'A'] } + ]); + }); + }, + async verify(page) { + // 升序后较小值区域 R2 必须早于 R1,并且点击事件已触发。 + await page.evaluate(() => { + const table = window.__visualTable; + const labels = Array.from({ length: table.rowCount }, (_, row) => table.getCellValue(0, row)); + const r2 = labels.indexOf('R2'); + const r1 = labels.indexOf('R1'); + if (window.__pivotSortClicks < 1 || r2 < 0 || r1 < 0 || r2 >= r1) + throw new Error(`透视排序规则未更新:${JSON.stringify({ clicks: window.__pivotSortClicks, labels })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/pivot/virtual-column-total.mjs b/packages/vtable/__tests__/visual/cases/pivot/virtual-column-total.mjs new file mode 100644 index 0000000000..c2e3628c21 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/pivot/virtual-column-total.mjs @@ -0,0 +1,48 @@ +/** + * BugServer case IDs: 66418ee40b3b8900c63a7808 + * 验证目的:虚拟列节点与行列小计、总计共同布局。 + * 改写:保留两层虚拟节点及合计规则,明细改为通用分组数据。 + */ +export default { + mount(container) { + // 虚拟节点自身不对应数据维度,子节点仍需参与聚合。 + return new window.VTable.PivotTable(container, { + rows: ['region', 'item'], columns: ['kind', 'variant'], indicators: ['amount', 'count'], + columnTree: [ + { dimensionKey: 'kind', value: 'Column Total', children: [] }, + { dimensionKey: 'virtualGroup', value: 'Virtual', virtual: true, children: [ + { dimensionKey: 'kind', value: 'A', children: [ + { dimensionKey: 'variant', value: 'A1', children: [] }, + { dimensionKey: 'variant', value: 'Subtotal', children: [] }, + { dimensionKey: 'variant', value: 'A2', children: [] } + ] }, + { dimensionKey: 'kind', value: 'B', children: [ + { dimensionKey: 'virtualNested', value: 'Nested virtual', virtual: true, children: [ + { dimensionKey: 'variant', value: 'Subtotal', children: [] }, + { dimensionKey: 'variant', value: 'B1', children: [] } + ] } + ] } + ] } + ], + dataConfig: { totals: { + row: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['region'], + grandTotalLabel: 'Row total', subTotalLabel: 'Subtotal', showSubTotalsOnTop: true, showGrandTotalsOnTop: true }, + column: { showGrandTotals: true, showSubTotals: true, subTotalsDimensions: ['kind'], + grandTotalLabel: 'Column Total', subTotalLabel: 'Subtotal', showSubTotalsOnLeft: true, showGrandTotalsOnLeft: true } + } }, + records: [ + { region: 'R1', item: 'I1', kind: 'A', variant: 'A1', amount: 10, count: 1 }, + { region: 'R1', item: 'I2', kind: 'A', variant: 'A2', amount: 20, count: 2 }, + { region: 'R2', item: 'I1', kind: 'B', variant: 'B1', amount: 30, count: 3 } + ], + enableDataAnalysis: true, indicatorsAsCol: false, corner: { titleOnDimension: 'row' }, widthMode: 'autoWidth' + }); + }, + async verify(page) { + // 虚拟表头展开后应有足够的行列,且原始数值仍可检索。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount < 5 || table.rowCount < 5) throw new Error('虚拟节点未展开'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/plugins/auto-fill-grid-tree-origin-value.mjs b/packages/vtable/__tests__/visual/cases/plugins/auto-fill-grid-tree-origin-value.mjs new file mode 100644 index 0000000000..c6050459a8 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/plugins/auto-fill-grid-tree-origin-value.mjs @@ -0,0 +1,59 @@ +/** + * BugServer case IDs: 69d614f2e41b43005d89a624 + * 验证目的:grid-tree 表头下拖动填充柄,插件将原单元格值复制到目标行。 + * 改写:用三条匿名记录重建树形表头,保留真实拖动和填充菜单选择。 + */ +export default { + mount(container) { + // 由 AutoFillPlugin 处理填充柄拖动,首列源格为固定文本 Seed。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, + { title: 'Group', columns: [{ field: 'name', title: 'Name', width: 150 }, + { field: 'value', title: 'Value', width: 150 }] }], + records: [{ id: 1, name: 'Seed', value: 10 }, + { id: 2, name: 'Other', value: 20 }, { id: 3, name: 'Third', value: 30 }], + headerHierarchyType: 'grid-tree', headerExpandLevel: 2, + excelOptions: { fillHandle: true }, + plugins: [new window.VTable.plugins.AutoFillPlugin()] + }); + return table; + }, + async exercise(page) { + // 先选中源值,再从实际填充柄拖至下一行并选择复制填充。 + const source = await page.evaluate(() => { + const table = window.__visualTable; + const row = table.columnHeaderLevelCount; + const b = table.scenegraph.getCell(1, row).globalAABBBounds; + const canvas = table.canvas.getBoundingClientRect(); + return { x: canvas.x + (b.x1 + b.x2) / 2, + y: canvas.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(source.x, source.y); + const points = await page.evaluate(() => { + const table = window.__visualTable; + const row = table.columnHeaderLevelCount; + const handle = [...table.scenegraph.selectedRangeComponents.values()][0]?.fillhandle?.globalAABBBounds; + if (!handle) throw new Error('列表填充柄未显示'); + const target = table.scenegraph.getCell(1, row + 1).globalAABBBounds; + const canvas = table.canvas.getBoundingClientRect(); + return { start: { x: canvas.x + (handle.x1 + handle.x2) / 2, + y: canvas.y + (handle.y1 + handle.y2) / 2 }, + end: { x: canvas.x + target.x2 - 8, y: canvas.y + target.y2 - 8 } }; + }); + await page.mouse.move(points.start.x, points.start.y); + await page.mouse.down(); + await page.mouse.move(points.end.x, points.end.y, { steps: 10 }); + await page.mouse.up(); + await page.locator('.vtable__menu-element__item').filter({ hasText: '复制填充' }).click(); + }, + async verify(page) { + // 目标行必须由 Other 变为 Seed,源行和下一行保持各自值。 + await page.evaluate(() => { + const table = window.__visualTable; + const row = table.columnHeaderLevelCount; + const values = [row, row + 1, row + 2].map(r => table.getCellValue(1, r)); + if (values[0] !== 'Seed' || values[1] !== 'Seed' || values[2] !== 'Third') + throw new Error(`列表复制填充结果错误:${values}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/plugins/context-menu-click.mjs b/packages/vtable/__tests__/visual/cases/plugins/context-menu-click.mjs new file mode 100644 index 0000000000..eb0c888202 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/plugins/context-menu-click.mjs @@ -0,0 +1,37 @@ +/** + * BugServer case IDs: 6a82b5b68e0a21005e44edc9 + * 验证目的:ContextMenuPlugin 的冻结菜单回调发出 context_menu_click 事件。 + * 改写:去掉状态文本面板,保留插件回调的 menuKey、行列坐标和事件。 + */ +export default { + mount(container) { + // 监听事件在执行菜单回调前安装,防止同步触发被漏掉。 + const plugin = new window.VTable.plugins.ContextMenuPlugin(); + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 80 }, { field: 'name', title: 'Name', width: 160 }, + { field: 'city', title: 'City', width: 160 }, { field: 'value', title: 'Value', width: 120 }], + records: Array.from({ length: 8 }, (_, i) => ({ id: i + 1, name: `Name ${i + 1}`, + city: ['North', 'South', 'East'][i % 3], value: Math.round((i + 1) * 12.6) })), + defaultRowHeight: 40, defaultHeaderRowHeight: 40, widthMode: 'standard', + heightMode: 'standard', plugins: [plugin] + }); + window.__contextPlugin = plugin; + window.__contextEvents = []; + table.on('context_menu_click', args => window.__contextEvents.push(args)); + return table; + }, + async exercise(page) { + // 调用来源相同的冻结菜单回调,验证插件而非手工伪造事件。 + await page.evaluate(() => { + const plugin = window.__contextPlugin; + if (typeof plugin.handleMenuClickCallback !== 'function') throw new Error('菜单插件回调缺失'); + plugin.handleMenuClickCallback({ menuKey: 'freeze_to_this_row_and_column', + menuText: 'Freeze to cell', rowIndex: 1, colIndex: 1 }, window.__visualTable); + }); + }, + async verify(page) { + // 必须收到插件真实发出的目标菜单事件。 + await page.waitForFunction(() => window.__contextEvents.some(event => + event.contextMenu?.menuKey === 'freeze_to_this_row_and_column')); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/plugins/excel-delete-range.mjs b/packages/vtable/__tests__/visual/cases/plugins/excel-delete-range.mjs new file mode 100644 index 0000000000..39e63f30ae --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/plugins/excel-delete-range.mjs @@ -0,0 +1,46 @@ +/** + * BugServer case IDs: 69292cda9995e4005d1533c2 + * 验证目的:ExcelEditCellKeyboardPlugin 用 Delete 清空选中的可编辑矩形范围。 + * 改写:以匿名短记录替换来源的姓名和邮箱;保留源码中的键盘动作。 + */ +export default { + mount(container) { + // 编辑器使插件默认的“仅清空可编辑单元格”规则有可验证目标。 + const VTable = window.VTable; + VTable.register.editor('input-editor', new VTable.editors.InputEditor({})); + return new VTable.ListTable(container, { + columns: [ + { field: 'id', title: 'ID', width: 80 }, + { field: 'name', title: 'Name', width: 150, editor: 'input-editor' }, + { field: 'score', title: 'Score', width: 120, editor: 'input-editor' } + ], + records: [ + { id: 1, name: 'A', score: '10' }, + { id: 2, name: 'B', score: '20' }, + { id: 3, name: 'C', score: '30' } + ], + editCellTrigger: ['keydown'], + plugins: [new VTable.plugins.ExcelEditCellKeyboardPlugin()] + }); + }, + async exercise(page) { + // 真实焦点、范围选择和 Delete 键与来源的内联动作一致。 + await page.evaluate(() => { + const table = window.__visualTable; + table.selectCells([{ start: { col: 1, row: 1 }, end: { col: 2, row: 2 } }]); + table.getElement().tabIndex = 0; + table.getElement().focus(); + }); + await page.keyboard.press('Delete'); + }, + async verify(page) { + // 所选四格必须清空,未选记录及不可编辑 ID 保持原值。 + await page.evaluate(() => { + const table = window.__visualTable; + for (const row of [1, 2]) for (const col of [1, 2]) + if (table.getCellOriginValue(col, row) !== '') throw new Error('选中范围未全部清空'); + if (table.getCellOriginValue(0, 1) !== 1 || table.getCellOriginValue(1, 3) !== 'C') + throw new Error('Delete 修改了未选或不可编辑单元格'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/plugins/fill-handle-hit-area.mjs b/packages/vtable/__tests__/visual/cases/plugins/fill-handle-hit-area.mjs new file mode 100644 index 0000000000..a189ffa806 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/plugins/fill-handle-hit-area.mjs @@ -0,0 +1,31 @@ +/** + * BugServer case IDs: 6aaf3468168db9005cc382c1 + * 验证目的:填充柄显示尺寸为 6×6,命中区域覆盖 11 像素且不覆盖 13 像素。 + * 改写:移除宿主状态标签,保留选中、两个偏移命中检查与尺寸断言。 + */ +export default { + mount(container) { + // 开启 fillHandle 并选中第一个数据格,命中检查在渲染后运行。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Name', width: 180 }, { field: 'value', title: 'Value', width: 180 }], + records: [{ name: 'Alpha', value: 1 }, { name: 'Beta', value: 2 }, { name: 'Gamma', value: 3 }], + excelOptions: { fillHandle: true } + }); + table.selectCell(0, 1); + return table; + }, + async verify(page) { + // 同时检查真实命中函数与显示大小,避免只有配置存在的假通过。 + await page.evaluate(() => { + const table = window.__visualTable; + const bounds = table.scenegraph.highPerformanceGetCell(0, 1).globalAABBBounds; + const hit = offset => table.eventManager.checkCellFillhandle({ + abstractPos: { x: bounds.x2 + offset, y: bounds.y2 + offset }, eventArgs: {} + }); + const component = [...table.scenegraph.selectedRangeComponents.values()][0]; + if (!component?.fillhandle || !hit(11) || hit(13) || + component.fillhandle.attribute.width !== 6 || component.fillhandle.attribute.height !== 6) + throw new Error('填充柄命中区域或视觉尺寸错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/plugins/filter-hide-reorder.mjs b/packages/vtable/__tests__/visual/cases/plugins/filter-hide-reorder.mjs new file mode 100644 index 0000000000..bacbf57573 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/plugins/filter-hide-reorder.mjs @@ -0,0 +1,47 @@ +/** + * BugServer case IDs: 6a0c22678e0a21005e44ed8a + * 验证目的:FilterPlugin 与隐藏列、移动表头连续执行后筛选结果仍正确。 + * 改写:来源的人事字段及示例姓名改为通用项,保留三步调用顺序。 + */ +export default { + mount(container) { + // 记录包含两种保留状态与一种剔除状态,验证插件筛选生效。 + const filter = new window.VTable.plugins.FilterPlugin({}); + const table = new window.VTable.ListTable(container, { + columns: [ + { field: 'id', title: 'ID', width: 80, sort: true }, + { field: 'name', title: 'Name', width: 120, sort: true }, + { field: 'group', title: 'Group', width: 100 }, + { field: 'city', title: 'City', width: 120 }, + { field: 'status', title: 'Status', width: 120 } + ], + records: [ + { id: 1, name: 'A', group: 'G1', city: 'North', status: 'Active' }, + { id: 2, name: 'B', group: 'G2', city: 'South', status: 'Leave' }, + { id: 3, name: 'C', group: 'G1', city: 'East', status: 'Active' }, + { id: 4, name: 'D', group: 'G2', city: 'West', status: 'Inactive' } + ], + padding: 10, dragHeaderMode: 'all', plugins: [filter] + }); + const nextColumns = table.columns.map(column => ({ ...column, ...(column.field === 'group' ? { hide: true } : {}) })); + table.updateColumns(nextColumns, { clearRowHeightCache: false }); + const city = table.columns.findIndex(column => column.field === 'city'); + const name = table.columns.findIndex(column => column.field === 'name'); + table.changeHeaderPosition({ source: { col: city, row: 0 }, target: { col: name + 1, row: 0 }, movingColumnOrRow: 'column' }); + filter.applyFilterSnapshot({ filters: [{ field: 'status', type: 'byValue', values: ['Active', 'Leave'], enable: true }] }); + return table; + }, + async verify(page) { + // 验证筛选剔除了目标记录,且隐藏列及表头移动调用未丢失。 + await page.evaluate(() => { + const table = window.__visualTable; + const records = table.getFilteredRecords(); + if (records.length !== 3 || records.some(record => record.status === 'Inactive')) + throw new Error('插件筛选结果错误'); + if (!table.columns.some(column => column.field === 'group' && column.hide)) + throw new Error('隐藏列未保留'); + if (table.columns.findIndex(column => column.field === 'city') <= table.columns.findIndex(column => column.field === 'name')) + throw new Error('表头移动未保留'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/plugins/highlight-header.mjs b/packages/vtable/__tests__/visual/cases/plugins/highlight-header.mjs new file mode 100644 index 0000000000..5be74880a3 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/plugins/highlight-header.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 68ef4fe025696c00b26a774c + * 验证目的:选中透视表体后插件高亮对应的行列标题。 + * 改写:多地区商品明细换成固定通用分组,保留插件双向高亮与点击。 + */ +export default { + mount(container) { + // 插件实例留作状态断言,表格数据构成可见的行列交叉。 + const plugin = new window.VTable.plugins.HighlightHeaderWhenSelectCellPlugin({ rowHighlight: true, colHighlight: true }); + window.__highlightPlugin = plugin; + return new window.VTable.PivotTable(container, { + plugins: [plugin], rows: ['group', 'item'], columns: ['kind', 'variant'], + indicators: ['amount', 'count'], indicatorsAsCol: false, enableDataAnalysis: true, + records: [{ group: 'A', item: 'A1', kind: 'X', variant: 'X1', amount: 10, count: 1 }, + { group: 'A', item: 'A2', kind: 'Y', variant: 'Y1', amount: 20, count: 2 }, + { group: 'B', item: 'B1', kind: 'X', variant: 'X1', amount: 30, count: 3 }] + }); + }, + async exercise(page) { + // 点击首个表体数值,对应来源的 click-cell 录制动作。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + const b = table.getCellRect(table.rowHeaderLevelCount, table.columnHeaderLevelCount).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 插件应产生行列标题高亮范围。 + await page.waitForFunction(() => window.__highlightPlugin.rowHeaderRanges.length && window.__highlightPlugin.colHeaderRanges.length); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/plugins/master-detail-auto-height.mjs b/packages/vtable/__tests__/visual/cases/plugins/master-detail-auto-height.mjs new file mode 100644 index 0000000000..b06a947663 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/plugins/master-detail-auto-height.mjs @@ -0,0 +1,53 @@ +/** + * BugServer case IDs: 68ff3ba0e9f70b00b11e9403 + * 验证目的:MasterDetailPlugin 自动高度子表在主表滚动后仍正确布局。 + * 改写:来源的人员、部门与薪资示例替换为固定任务数据,保留展开子表、自动高度及滚动条件。 + */ +export default { + mount(container) { + // 每四条记录带一张子表,保证容器高度不足时仍有已展开的明细。 + container.style.width = '800px'; + container.style.height = '400px'; + const plugin = new window.VTable.plugins.MasterDetailPlugin({ + id: 'visual-master-detail', + detailTableOptions: { + columns: [{ field: 'step', title: 'Step', width: 180 }, + { field: 'state', title: 'State', width: 140 }], + defaultRowHeight: 35, defaultHeaderRowHeight: 35, + style: { margin: 15, height: 'auto' }, theme: window.VTable.themes.ARCO + } + }); + const records = Array.from({ length: 12 }, (_, index) => ({ + id: index + 1, item: `Task ${index + 1}`, stage: index % 2 ? 'Pending' : 'Ready', + hierarchyState: 'expand', + children: index % 4 === 0 ? [ + { step: 'Prepare', state: 'Done' }, { step: 'Review', state: 'Open' } + ] : undefined + })); + return new window.VTable.ListTable(container, { + records, + columns: [{ field: 'id', title: 'ID', width: 80 }, + { field: 'item', title: 'Task', width: 180 }, + { field: 'stage', title: 'Stage', width: 130 }], + rowSeriesNumber: { title: '#', dragOrder: true, width: 'auto' }, + widthMode: 'standard', heightMode: 'standard', defaultRowHeight: 40, + defaultHeaderRowHeight: 45, plugins: [plugin] + }); + }, + async exercise(page) { + // 将来源滚动条到底动作转换为定位最后一行,避免固定像素坐标。 + await page.evaluate(() => { + const table = window.__visualTable; + table.scrollToCell({ row: table.rowCount - 1 }); + }); + }, + async verify(page) { + // 滚动后主从展开行仍要保持自动高度和有效布局。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getHierarchyState(1, 1) !== 'expand' || table.getRowHeight(1) <= 40 || + table.scrollTop <= 0 || !Number.isFinite(table.getCellRect(1, table.rowCount - 1)?.bounds?.y1)) + throw new Error(`MasterDetail 明细或滚动未生效:height=${table.getRowHeight(1)}, top=${table.scrollTop}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/records/add-merged.mjs b/packages/vtable/__tests__/visual/cases/records/add-merged.mjs new file mode 100644 index 0000000000..b02143f3a1 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/records/add-merged.mjs @@ -0,0 +1,29 @@ +/** + * BugServer case IDs: 6614e7b82acd9c00d1c98a62 + * 验证目的:合并单元格分组中插入记录后合并区域正常重算。 + * 改写:分组和编号改成通用值,保留中间位置插入。 + */ +export default { + mount(container) { + // 在第二组已有记录前插入同组项目。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'select', title: '', cellType: 'checkbox', headerType: 'checkbox', width: 60 }, + { field: 'group', title: 'Group', width: 120 }, + { field: 'label', title: 'Label', width: 150, mergeCell: true }, + { field: 'id', title: 'ID', width: 100 }], + records: [{ group: 'A', label: 'Group A', id: 1 }, { group: 'A', label: 'Group A', id: 2 }, + { group: 'B', label: 'Group B', id: 3 }, { group: 'B', label: 'Group B', id: 4 }], + autoWrapText: true, heightMode: 'autoHeight' + }); + table.addRecord({ group: 'B', label: 'Group B', id: 5 }, 3); + return table; + }, + async verify(page) { + // 插入记录可见,且合并列仍返回组名。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount !== 6 || table.getCellOriginValue(3, 4) !== 5 || table.getCellValue(2, 4) !== 'Group B') + throw new Error('合并分组插入失败'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/records/add-pagination.mjs b/packages/vtable/__tests__/visual/cases/records/add-pagination.mjs new file mode 100644 index 0000000000..4b6be97fd6 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/records/add-pagination.mjs @@ -0,0 +1,31 @@ +/** + * BugServer case IDs: 656d91b34c3611faee9b1b2e + * 验证目的:第一页开启分页时,在记录索引 2 批量插入后可见顺序正确。 + * 改写:人员资料与联系方式替换为固定编号,保留 50 条数据和每页 7 条配置。 + */ +export default { + mount(container) { + // 两条新记录应出现在第一页第 3-4 条,后续旧记录顺延。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, + { field: 'label', title: 'Label', width: 180 }, + { field: 'progress', title: 'Progress', width: 130 }], + records: Array.from({ length: 50 }, (_, index) => ({ id: index + 1, + label: `Item ${index + 1}`, progress: index })), + pagination: { perPageCount: 7, currentPage: 0 }, + widthMode: 'standard', allowFrozenColCount: 2 + }); + table.addRecords([{ id: 201, label: 'Inserted A' }, + { id: 202, label: 'Inserted B' }], 2); + return table; + }, + async verify(page) { + // 插入必须影响分页当前页而非仅修改原始 records 数组。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.records.length !== 52 || table.getCellValue(0, 3) !== 201 || + table.getCellValue(0, 4) !== 202 || table.getCellValue(0, 5) !== 3) + throw new Error('分页插入顺序错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/records/add-sorted.mjs b/packages/vtable/__tests__/visual/cases/records/add-sorted.mjs new file mode 100644 index 0000000000..15fd41a963 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/records/add-sorted.mjs @@ -0,0 +1,30 @@ +/** + * BugServer case IDs: 656d93014c3611faee9b1b30 + * 验证目的:按名字排序后,在索引 2 插入缺名字和有名字的两条记录。 + * 改写:保留 50 条初始记录、排序状态和两种插入边界,去掉展示型 SVG。 + */ +export default { + mount(container) { + // 在已有排序状态下批量插入,其中第一条缺失排序字段。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, + { field: 'fname', title: 'First Name', width: 150, sort: true }, + { field: 'progress', title: 'Progress', width: 120 }], + widthMode: 'standard' + }); + const names = ['Sophia', 'Emma', 'Olivia', 'Isabella', 'Ava', 'Mia', 'Emily', 'Abigail', 'Madison', 'Elizabeth']; + table.setRecords(Array.from({ length: 50 }, (_, i) => + ({ id: i + 1, fname: names[i % names.length], progress: i })), { field: 'fname', order: 'asc' }); + table.addRecords([{ id: 201 }, { id: 202, fname: 'Zsagjkf' }], 2); + return table; + }, + async verify(page) { + // 两条不同形态的新记录都应保留,且排序状态没有丢失。 + await page.evaluate(() => { + const table = window.__visualTable; + const ids = Array.from({ length: table.rowCount - 1 }, (_, i) => table.getCellOriginValue(0, i + 1)); + if (table.rowCount !== 53 || !ids.includes(201) || !ids.includes(202) || table.sortState?.field !== 'fname') + throw new Error('排序后插入记录失败'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/records/array-path-add.mjs b/packages/vtable/__tests__/visual/cases/records/array-path-add.mjs new file mode 100644 index 0000000000..26501c4451 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/records/array-path-add.mjs @@ -0,0 +1,26 @@ +/** + * BugServer case IDs: 676e651d8e768100b114c2ef + * 验证目的:点号路径读取数组元素字段后,在中间插入一条数组记录。 + * 改写:将姓名改为通用标识,保留 0.name/1.age/2.sex 路径和插入位置。 + */ +export default { + mount(container) { + // 数组记录的路径与普通对象路径不同,插入后还须能解析。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: '0.name', title: 'Name', width: 130, sort: true }, + { field: '1.age', title: 'Age', width: 100 }, { field: '2.sex', title: 'Sex', width: 100 }], + records: [[{ name: 'A' }, { age: 20 }, { sex: 'male' }], + [{ name: 'B' }, { age: 21 }, { sex: 'female' }], [{ name: 'C' }, { age: 22 }, { sex: 'male' }]] + }); + table.addRecord([{ name: 'Inserted' }, { age: 23 }, { sex: 'female' }], 2); + return table; + }, + async verify(page) { + // 插入后的三个字段必须落在同一行。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 3) !== 'Inserted' || table.getCellValue(1, 3) !== 23 || + table.getCellValue(2, 3) !== 'female') throw new Error('数组字段路径或插入位置错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/records/delete-all-bottom-frozen.mjs b/packages/vtable/__tests__/visual/cases/records/delete-all-bottom-frozen.mjs new file mode 100644 index 0000000000..c391eaa2de --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/records/delete-all-bottom-frozen.mjs @@ -0,0 +1,26 @@ +/** + * BugServer case IDs: 6aacad9f168db9005cc382be + * 验证目的:删除全部记录后,底部冻结行数量重置为零。 + * 改写:保留左右冻结列、底部冻结行和删除两条记录的顺序。 + */ +export default { + mount(container) { + // 先创建冻结区,再删除所有数据,防止仅测试空表初始化。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 120 }, { field: 'name', title: 'Name', width: 300 }, + { field: 'total', title: 'Total', width: 180 }], + records: [{ id: 1, name: 'A', total: 120 }, { id: 2, name: 'B', total: 180 }], + frozenColCount: 1, rightFrozenColCount: 1, bottomFrozenRowCount: 1, widthMode: 'standard' + }); + table.deleteRecords([0, 1]); + return table; + }, + async verify(page) { + // 删除必须同时清空数据和底部冻结计数。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.records.length !== 0 || table.bottomFrozenRowCount !== 0) + throw new Error('删除全部记录后仍保留底部冻结行'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/records/delete-merged.mjs b/packages/vtable/__tests__/visual/cases/records/delete-merged.mjs new file mode 100644 index 0000000000..f41f5f5045 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/records/delete-merged.mjs @@ -0,0 +1,29 @@ +/** + * BugServer case IDs: 6614e84b2acd9c00d1c98a63 + * 验证目的:删除合并单元格分组中的记录后合并区域正常重算。 + * 改写:使用通用编号,保留末行删除。 + */ +export default { + mount(container) { + // 删除第二组末行,防止合并边界残留。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'select', title: '', cellType: 'checkbox', headerType: 'checkbox', width: 60 }, + { field: 'group', title: 'Group', width: 120 }, + { field: 'label', title: 'Label', width: 150, mergeCell: true }, + { field: 'id', title: 'ID', width: 100 }], + records: [{ group: 'A', label: 'Group A', id: 1 }, { group: 'A', label: 'Group A', id: 2 }, + { group: 'B', label: 'Group B', id: 3 }, { group: 'B', label: 'Group B', id: 4 }], + autoWrapText: true, heightMode: 'autoHeight' + }); + table.deleteRecords([3]); + return table; + }, + async verify(page) { + // 最后一行变为 ID 3,合并组名仍可见。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount !== 4 || table.getCellOriginValue(3, 3) !== 3 || table.getCellValue(2, 3) !== 'Group B') + throw new Error('合并分组删除失败'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/records/delete-sorted.mjs b/packages/vtable/__tests__/visual/cases/records/delete-sorted.mjs new file mode 100644 index 0000000000..6bc1ac85a9 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/records/delete-sorted.mjs @@ -0,0 +1,30 @@ +/** + * BugServer case IDs: 656d995d4c3611faee9b1b33 + * 验证目的:按名字排序的 50 条记录删除索引 0 和 8 后重算表体。 + * 改写:保留排序与双索引删除,去掉展示型 SVG 和无关字段。 + */ +export default { + mount(container) { + // 排序完成后删除两个非连续索引,检查记录数和排序状态。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100 }, + { field: 'fname', title: 'First Name', width: 150, sort: true }, + { field: 'progress', title: 'Progress', width: 120 }], + widthMode: 'standard' + }); + const names = ['Sophia', 'Emma', 'Olivia', 'Isabella', 'Ava', 'Mia', 'Emily', 'Abigail', 'Madison', 'Elizabeth']; + table.setRecords(Array.from({ length: 50 }, (_, i) => + ({ id: i + 1, fname: names[i % names.length], progress: i })), { field: 'fname', order: 'asc' }); + table.deleteRecords([0, 8]); + return table; + }, + async verify(page) { + // 删除后只余 48 条记录,排序状态仍指向 fname。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount !== 49 || table.sortState?.field !== 'fname' || + !Number.isFinite(table.getCellRect(1, 48)?.bounds?.y1)) + throw new Error('排序后删除记录失败'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/records/update-merged.mjs b/packages/vtable/__tests__/visual/cases/records/update-merged.mjs new file mode 100644 index 0000000000..d596709e22 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/records/update-merged.mjs @@ -0,0 +1,37 @@ +/** + * BugServer case IDs: 6614e8b42acd9c00d1c98a64 + * 验证目的:分组合并单元格存在时,updateRecords 更新目标记录且合并范围保持正确。 + * 改写:用通用组编号与短文本替换原示例,保留五行分组和索引 1 的更新路径。 + */ +export default { + mount(container) { + // 前三行同组、后两行同组,用于检查更新后的跨行合并。 + const table = new window.VTable.ListTable(container, { + columns: [ + { field: 'select', title: '', width: 60, cellType: 'checkbox', headerType: 'checkbox' }, + { field: 'groupNo', title: 'Group No.', width: 120 }, + { field: 'groupName', title: 'Group', width: 150, mergeCell: true }, + { field: 'item', title: 'Item', width: 120 } + ], + records: [ + { groupNo: '0001', groupName: 'Group 1', item: 'A' }, + { groupNo: '0001', groupName: 'Group 1', item: 'B' }, + { groupNo: '0001', groupName: 'Group 1', item: 'C' }, + { groupNo: '0002', groupName: 'Group 2', item: 'D' }, + { groupNo: '0002', groupName: 'Group 2', item: 'E' } + ], + autoWrapText: true, heightMode: 'autoHeight' + }); + table.updateRecords([{ groupNo: '0001', groupName: 'Group 1', item: 'Updated' }], [1]); + return table; + }, + async verify(page) { + // 更新值与第一组合并区间都应真实可读。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(3, 2) !== 'Updated') throw new Error('记录更新未生效'); + const range = table.getCellRange(2, 1); + if (range.start.row !== 1 || range.end.row !== 3) throw new Error('分组合并范围错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/scroll/bar-to-side.mjs b/packages/vtable/__tests__/visual/cases/scroll/bar-to-side.mjs new file mode 100644 index 0000000000..9ca8d009a5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/scroll/bar-to-side.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 66055f07a8fccc00acd3703a + * 验证目的:列溢出时滚动条贴边且始终可见,多层表头保持布局。 + * 改写:原例人物资料改为固定通用记录,保留列宽和 scrollStyle 关键设置。 + */ +export default { + mount(container) { + // 宽度总和超过 800px,确保可见滚动条不是无溢出的空配置。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 'calc(20% - 20px)', + fieldFormat: record => `Completed ${record.progress}%` }, + { title: 'Name', columns: [ + { field: 'first', title: 'First name', width: 180, minWidth: 150 }, + { field: 'last', title: 'Last name', width: '20%', minWidth: 150 } + ] }, + { field: 'note', title: 'Note', width: 650 } + ], + records: [{ progress: 20, first: 'A', last: 'One', note: 'first' }, + { progress: 50, first: 'B', last: 'Two', note: 'second' }], + dragHeaderMode: 'column', defaultHeaderColWidth: [80, 150], + theme: { scrollStyle: { barToSide: true, visible: 'always' } } + }); + }, + async verify(page) { + // 验证水平内容确实溢出,否则滚动条样式无法得到有效测试。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getAllColsWidth() <= 800 || table.getCellValue(1, 3) !== 'B') + throw new Error('滚动表格未形成预期溢出'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/scroll/pivot-wide-rail.mjs b/packages/vtable/__tests__/visual/cases/scroll/pivot-wide-rail.mjs new file mode 100644 index 0000000000..8abdc24c87 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/scroll/pivot-wide-rail.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 689af66784406200a7ab3d6c + * 验证目的:透视表宽滚动条、贴边轨道、主题条纹和可调整行高共同布局。 + * 改写:用固定组合生成多行多列,保留 30 像素轨道及 always 配置。 + */ +export default { + mount(container) { + // 足够的维度组合同时触发水平和垂直滚动条。 + const records = []; + for (let group = 0; group < 8; group++) + for (let item = 0; item < 3; item++) + for (let kind = 0; kind < 8; kind++) + records.push({ group: `G${group}`, item: `I${item}`, kind: `K${kind}`, variant: `V${kind}`, + amount: group * 100 + item * 10 + kind, count: 1 }); + return new window.VTable.PivotTable(container, { + rows: ['group', 'item'], columns: ['kind', 'variant'], indicators: ['amount', 'count'], + records, enableDataAnalysis: true, indicatorsAsCol: false, + rowResizeType: 'indicator', rowResizeMode: 'all', widthMode: 'autoWidth', + theme: window.VTable.themes.ARCO.extends({ + scrollStyle: { width: 30, visible: 'always', hoverOn: false, barToSide: true, + scrollSliderCornerRadius: 0, scrollRailColor: 'rgba(216,216,216,.5)', scrollSliderColor: 'rgba(170,170,170,1)' }, + bodyStyle: { bgColor: args => (args.row - args.table.frozenRowCount) % 2 ? '#f8f8f8' : '#ffffff' } + }) + }); + }, + async verify(page) { + // 透视尺寸应超过视口,确保滚动条确实有内容可滚动。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount < 8 || table.rowCount < 20) throw new Error('透视滚动范围不足'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/add-columns.mjs b/packages/vtable/__tests__/visual/cases/sheet/add-columns.mjs new file mode 100644 index 0000000000..18f60a0828 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/add-columns.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 68ff1c25f11ddd816792f91a + * 验证目的:Sheet 中公式和表格列插入后仍能渲染。 + * 改写:移除与列插入无关的菜单、主题和实验性插件配置,保留公式与 addColumns 调用。 + */ +export default { + mount(container) { + // 使用固定矩阵避免异步数据源影响列插入的可重复性。 + container.style.height = '400px'; + const sheet = new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ + rowCount: 20, columnCount: 10, sheetKey: 'sheet1', sheetTitle: 'Sheet 1', + filter: true, columns: [{ title: 'Name', sort: true, width: 100 }], + data: [[1, 2, 3], [4, 5, 6], [7, 8, 9], ['A', 'B', 'C']], active: true + }] + }); + sheet.formulaManager.setCellContent({ sheet: 'sheet1', row: 4, col: 3 }, '=SUM(A2:C2)'); + const table = sheet.getActiveSheet().tableInstance; + window.__sheetColsBefore = table.colCount; + table.addColumns([{ field: 3 }, { field: 4 }], 3, true); + return sheet; + }, + async verify(page) { + // 断言活动 Sheet 存在且插入操作改变列结构。 + await page.evaluate(() => { + const sheet = window.__visualTable; + const table = sheet.getActiveSheet()?.tableInstance; + if (!table || table.colCount <= window.__sheetColsBefore) throw new Error('Sheet 列插入未生效'); + if (!Number.isFinite(table.getCellRect(1, 1)?.bounds?.x1)) throw new Error('Sheet 单元格未布局'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/add-rows.mjs b/packages/vtable/__tests__/visual/cases/sheet/add-rows.mjs new file mode 100644 index 0000000000..1af92ca9b6 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/add-rows.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 68ff1f7b25696c00b26a7761 + * 验证目的:Sheet 调整行高并插入两行后,原有数据仍位于正确位置。 + * 改写:保留行高调整、addRecords 与编辑动作,移除无关菜单和插件。 + */ +export default { + mount(container) { + // 固定矩阵及公式,使插入前后的单元格位置可验证。 + container.style.height = '400px'; + const sheet = new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Sheet 1', rowCount: 20, columnCount: 10, + columns: [{ title: 'Name', sort: true, width: 100 }], + data: [[1, 2, 3], [4, 5, 6], [7, 8, 9], ['A', 'B', 'C']], active: true }] + }); + const table = sheet.getActiveSheet().tableInstance; + const cell = { sheet: 'sheet1', row: 4, col: 3 }; + sheet.formulaManager.setCellContent(cell, '=SUM(A2:C2,B3,C3)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error) throw new Error(`SUM 公式错误:${result.error}`); + table.changeCellValue(3, 4, result.value, false, false); + table.stateManager.startResizeRow(4, 230, 50); + table.stateManager.updateResizeRow(390, 80); + table.stateManager.endResizeRow(); + window.__sheetRowsBefore = table.rowCount; + table.addRecords([[], []], 2, true); + table.startEditCell(3, 6); + return sheet; + }, + async verify(page) { + // 两行插入必须改变表格结构,原有非空记录不能丢失。 + await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + if (table.rowCount < window.__sheetRowsBefore + 2) + throw new Error(`Sheet 插行未生效:${window.__sheetRowsBefore} -> ${table.rowCount}`); + if (!Number.isFinite(table.getCellRect(1, 5)?.bounds?.x1)) + throw new Error('Sheet 插行后单元格未布局'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/copy-paste.mjs b/packages/vtable/__tests__/visual/cases/sheet/copy-paste.mjs new file mode 100644 index 0000000000..83d546f680 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/copy-paste.mjs @@ -0,0 +1,62 @@ +/** + * BugServer case IDs: 6927f3ea215128005c991a9d + * 验证目的:Sheet 公式结果经 copy 事件进入剪贴板,再经 paste 事件写入目标格。 + * 改写:移除菜单与展示插件,保留公式、同一 clipboardData 和事件先后顺序。 + */ +export default { + mount(container) { + // 先把 SUM 结果写回表格,供复制事件读取。 + const sheet = new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Sheet 1', + rowCount: 30, columnCount: 10, data: [[1, 2, 3], [4, 5, 6], [7, 8, 9]], active: true }] + }); + const cell = { sheet: 'sheet1', row: 5, col: 0 }; + sheet.formulaManager.setCellContent(cell, '=SUM(A2:A4)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error) throw new Error(`Sheet 源公式错误: ${result.error}`); + const table = sheet.getActiveSheet().tableInstance; + table.changeCellValue(0, 5, result.value); + window.__sheetCopySource = result.value; + return sheet; + }, + async exercise(page) { + // 使用一个事件剪贴板对象重放来源的复制和粘贴顺序。 + await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + const element = table.getElement(); + const store = {}; + const clipboardData = { setData(type, value) { store[type] = value; }, + getData(type) { return store[type] || ''; }, clearData(type) { delete store[type]; } }; + Object.defineProperty(clipboardData, 'types', { get: () => Object.keys(store) }); + const clipboardEvent = type => { + const event = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: clipboardData }); + return event; + }; + const keyEvent = (key, keyCode) => { + const event = document.createEvent('Event'); + event.initEvent('keydown', true, false); + Object.assign(event, { ctrlKey: true, metaKey: true, altKey: true, + which: keyCode, keyCode, key, code: 'Key' + key.toUpperCase() }); + element.focus(); + element.dispatchEvent(event); + }; + table.selectCell(0, 5); + keyEvent('c', 67); + element.dispatchEvent(clipboardEvent('copy')); + window.__sheetCopiedText = store['text/plain'] || store.text || ''; + table.selectCell(1, 5); + keyEvent('v', 86); + element.dispatchEvent(clipboardEvent('paste')); + }); + }, + async verify(page) { + // 剪贴板必须真的收到公式结果,目标格必须真的改变。 + await page.waitForFunction(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + return window.__sheetCopiedText && + String(window.__sheetCopiedText).trim() === String(window.__sheetCopySource) && + String(table.getCellValue(1, 5)).trim() === String(window.__sheetCopySource); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/cross-tab-formula.mjs b/packages/vtable/__tests__/visual/cases/sheet/cross-tab-formula.mjs new file mode 100644 index 0000000000..1200f26b68 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/cross-tab-formula.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 693a765f2f9376005c0328e3 + * 验证目的:第二张 Sheet 的跨表 SUM 范围在第一张 Sheet 显示计算结果。 + * 改写:原范围重复写入 Sheet 名,改为受支持的单表范围;保留中文感叹号归一化条件。 + */ +export default { + mount(container) { + // 两张表使用固定矩阵,Data!A2:A4 的结果应为 11。 + const sheet = new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Summary', rowCount: 20, columnCount: 8, + data: [[1, 2, 3], [4, 5, 6]], active: true }, + { sheetKey: 'sheet2', sheetTitle: 'Data', rowCount: 20, columnCount: 8, + data: [[2, 4, 6], [4, 5, 6], [7, 8, 9], ['A', 'B', 'C']] }] + }); + const cell = { sheet: 'sheet1', row: 5, col: 0 }; + sheet.formulaManager.setCellContent(cell, '=SUM(Data!A2:A4)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error) throw new Error(`跨 Sheet SUM 错误:${result.error}`); + window.__crossSheetValue = result.value; + sheet.getActiveSheet().tableInstance.changeCellValue(0, 5, result.value, false, false); + return sheet; + }, + async verify(page) { + // 公式管理器与可见单元格都应保存第二张表的求和值。 + await page.evaluate(() => { + const sheet = window.__visualTable; + const value = sheet.getActiveSheet().tableInstance.getCellValue(0, 5); + if (window.__crossSheetValue !== 11 || value !== 11) + throw new Error(`跨 Sheet 计算值错误:${window.__crossSheetValue}, ${value}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/formula-auto-fill-copy.mjs b/packages/vtable/__tests__/visual/cases/sheet/formula-auto-fill-copy.mjs new file mode 100644 index 0000000000..f39e1a3f42 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/formula-auto-fill-copy.mjs @@ -0,0 +1,58 @@ +/** + * BugServer case IDs: 692597e7cee9a0005d063c64 + * 验证目的:Sheet 公式结果格拖动填充柄并选择复制填充后,邻列写入相同显示值。 + * 改写:保留来源的 SUM(A2:A4) 和点击、拖动、菜单选择顺序,移除无关主菜单与主题。 + */ +export default { + mount(container) { + // 固定矩阵令 D5 的求和结果与复制到 E5 后的显示值都可预测。 + const sheet = new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Sheet 1', rowCount: 20, columnCount: 10, + data: [[1, 2, 3], [4, 5, 6], [7, 8, 9], ['A', 'B', 'C']], active: true }] + }); + const cell = { sheet: 'sheet1', row: 4, col: 3 }; + sheet.formulaManager.setCellContent(cell, '=SUM(A2:A4)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error) throw new Error(`源公式错误:${result.error}`); + sheet.getActiveSheet().tableInstance.changeCellValue(3, 4, result.value, false, false); + window.__fillSourceValue = result.value; + return sheet; + }, + async exercise(page) { + // 先选中源单元格,按填充柄实际边界拖到右侧,再点击复制填充菜单。 + const cellCenter = await page.evaluate(() => { + const bounds = window.__visualTable.getActiveSheet().tableInstance.scenegraph.getCell(3, 4).globalAABBBounds; + const canvas = window.__visualTable.getActiveSheet().tableInstance.canvas.getBoundingClientRect(); + return { x: canvas.x + (bounds.x1 + bounds.x2) / 2, + y: canvas.y + (bounds.y1 + bounds.y2) / 2 }; + }); + await page.mouse.click(cellCenter.x, cellCenter.y); + const points = await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + const selected = [...table.scenegraph.selectedRangeComponents.values()][0]; + const handle = selected?.fillhandle?.globalAABBBounds; + if (!handle) throw new Error('Sheet 填充柄未显示'); + const target = table.scenegraph.getCell(4, 4).globalAABBBounds; + const canvas = table.canvas.getBoundingClientRect(); + return { start: { x: canvas.x + (handle.x1 + handle.x2) / 2, + y: canvas.y + (handle.y1 + handle.y2) / 2 }, + end: { x: canvas.x + target.x2 - 8, y: canvas.y + target.y2 - 8 } }; + }); + await page.mouse.move(points.start.x, points.start.y); + await page.mouse.down(); + await page.mouse.move(points.end.x, points.end.y, { steps: 10 }); + await page.mouse.up(); + await page.locator('.vtable__menu-element__item').filter({ hasText: '复制填充' }).click(); + }, + async verify(page) { + // 当前复制填充行为将源格显示值 11 写到相邻单元格,源格仍为 11。 + await page.evaluate(() => { + const sheet = window.__visualTable; + const table = sheet.getActiveSheet().tableInstance; + if (window.__fillSourceValue !== 11 || table.getCellValue(3, 4) !== 11 || + table.getCellValue(4, 4) !== 11) + throw new Error(`公式自动填充结果错误:${table.getCellValue(3, 4)}, ${table.getCellValue(4, 4)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/formula-edit.mjs b/packages/vtable/__tests__/visual/cases/sheet/formula-edit.mjs new file mode 100644 index 0000000000..2a2924c120 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/formula-edit.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 690076c72ab33400b4abb33a + * 验证目的:Sheet 跨区域 SUM 公式回填后进入单元格编辑状态。 + * 改写:保留公式与编辑顺序,移除与此目的无关的菜单和插件配置。 + */ +export default { + mount(container) { + // 固定数据让 SUM(A2:C2,B3,C3) 的结果可预测。 + const sheet = new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Sheet 1', rowCount: 20, columnCount: 10, + columns: [{ title: 'Name', sort: true, width: 100 }], + data: [[1, 2, 3], [4, 5, 6], [7, 8, 9]], active: true }] + }); + const cell = { sheet: 'sheet1', row: 4, col: 3 }; + sheet.formulaManager.setCellContent(cell, '=SUM(A2:C2,B3,C3)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error) throw new Error(`SUM 公式错误:${result.error}`); + window.__formulaResult = result.value; + const table = sheet.getActiveSheet().tableInstance; + table.changeCellValue(3, 4, result.value, false, false); + table.startEditCell(3, 4); + return sheet; + }, + async verify(page) { + // 公式结果应保存在表格对应单元格中。 + await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + if (!Number.isFinite(window.__formulaResult) || table.getCellValue(3, 4) !== window.__formulaResult) + throw new Error(`公式结果错误:${window.__formulaResult}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/insert-columns-before-second.mjs b/packages/vtable/__tests__/visual/cases/sheet/insert-columns-before-second.mjs new file mode 100644 index 0000000000..9ab30bca51 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/insert-columns-before-second.mjs @@ -0,0 +1,48 @@ +/** 为列与行插入边界用例创建相同的匿名 Sheet 初始矩阵。 */ +function createInsertSheet(container) { + container.style.height = '400px'; + return new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Sheet 1', rowCount: 200, columnCount: 10, + columns: [{ title: 'Values', width: 100 }], + data: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], active: true }] + }); +} + +/** + * BugServer case IDs: 68ff1d7f07d0a000a8414c50 + * 验证目的:公式回填与两列宽调整后,在第二列前插入两列。 + * 改写:使用匿名数值矩阵,保留公式、列宽调整、插入位置及编辑动作。 + */ +export default { + mount(container) { + // 固定求和值使列插入前的公式结果可以直接校验。 + const sheet = createInsertSheet(container); + const cell = { sheet: 'sheet1', row: 4, col: 3 }; + sheet.formulaManager.setCellContent(cell, '=SUM(A2:C2)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error || result.value !== 6) throw new Error(`SUM 结果错误:${JSON.stringify(result)}`); + const table = sheet.getActiveSheet().tableInstance; + table.changeCellValue(3, 4, result.value, false, false); + table.stateManager.startResizeCol(0, 230, 50); + table.stateManager.updateResizeCol(300, 50); + table.stateManager.endResizeCol(); + table.stateManager.startResizeCol(1, 230, 50); + table.stateManager.updateResizeCol(150, 50); + table.stateManager.endResizeCol(); + window.__insertColumnsBefore = table.colCount; + table.addColumns([{ field: 1 }, { field: 4 }], 1, true); + table.startEditCell(5, 4); + return sheet; + }, + async verify(page) { + // 新列必须位于原第二列前,原数值要保持顺序并向右移动两格。 + await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + if (table.colCount !== window.__insertColumnsBefore + 2 || + table.getCellValue(0, 1) !== 1 || table.getCellValue(1, 1) !== undefined || + table.getCellValue(2, 1) !== undefined || table.getCellValue(3, 1) !== 2) + throw new Error('Sheet 第二列前插入位置或数值错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/insert-columns-before-third.mjs b/packages/vtable/__tests__/visual/cases/sheet/insert-columns-before-third.mjs new file mode 100644 index 0000000000..036ff3947b --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/insert-columns-before-third.mjs @@ -0,0 +1,48 @@ +/** 为列与行插入边界用例创建相同的匿名 Sheet 初始矩阵。 */ +function createInsertSheet(container) { + container.style.height = '400px'; + return new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Sheet 1', rowCount: 200, columnCount: 10, + columns: [{ title: 'Values', width: 100 }], + data: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], active: true }] + }); +} + +/** + * BugServer case IDs: 68ff1e7c479d9000abdefaf9 + * 验证目的:保留多区域公式配置,在第三列前插入两列且原数据右移。 + * 改写:使用匿名数值矩阵,保留列宽调整、插入位置及编辑动作。 + */ +export default { + mount(container) { + // 只设置公式,与来源一样不在插列前手动回填计算值。 + const sheet = createInsertSheet(container); + const cell = { sheet: 'sheet1', row: 4, col: 3 }; + sheet.formulaManager.setCellContent(cell, '=SUM(A2:C2,B3,C3)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error || result.value !== 17) throw new Error(`SUM 结果错误:${JSON.stringify(result)}`); + const table = sheet.getActiveSheet().tableInstance; + table.stateManager.startResizeCol(1, 230, 50); + table.stateManager.updateResizeCol(200, 50); + table.stateManager.endResizeCol(); + table.stateManager.startResizeCol(3, 230, 50); + table.stateManager.updateResizeCol(300, 50); + table.stateManager.endResizeCol(); + window.__insertColumnsBefore = table.colCount; + table.addColumns([{ field: 0 }, { field: 4 }], 2, true); + table.startEditCell(5, 4); + return sheet; + }, + async verify(page) { + // 原前两列不变,新增两列为空,原第三列位于第五列。 + await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + if (table.colCount !== window.__insertColumnsBefore + 2 || + table.getCellValue(0, 1) !== 1 || table.getCellValue(1, 1) !== 2 || + table.getCellValue(2, 1) !== undefined || table.getCellValue(3, 1) !== undefined || + table.getCellValue(4, 1) !== 3) + throw new Error('Sheet 第三列前插入位置或数值错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/insert-rows-after-first.mjs b/packages/vtable/__tests__/visual/cases/sheet/insert-rows-after-first.mjs new file mode 100644 index 0000000000..33c9d29d54 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/insert-rows-after-first.mjs @@ -0,0 +1,45 @@ +/** 为列与行插入边界用例创建相同的匿名 Sheet 初始矩阵。 */ +function createInsertSheet(container) { + container.style.height = '400px'; + return new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Sheet 1', rowCount: 200, columnCount: 10, + columns: [{ title: 'Values', width: 100 }], + data: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], active: true }] + }); +} + +/** + * BugServer case IDs: 6982e5a97a558400611ce04b + * 验证目的:公式回填与行高调整后,在第二条记录前插入两行。 + * 改写:使用匿名数值矩阵,保留插入位置与编辑动作。 + */ +export default { + mount(container) { + // 与首行插入用例分别启动工作表,验证不同的记录边界。 + const sheet = createInsertSheet(container); + const cell = { sheet: 'sheet1', row: 4, col: 3 }; + sheet.formulaManager.setCellContent(cell, '=SUM(A2:C2,B3,C3)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error || result.value !== 17) throw new Error(`SUM 结果错误:${JSON.stringify(result)}`); + const table = sheet.getActiveSheet().tableInstance; + table.changeCellValue(3, 4, result.value, false, false); + table.stateManager.startResizeRow(4, 230, 50); + table.stateManager.updateResizeRow(390, 80); + table.stateManager.endResizeRow(); + window.__insertRowsBefore = table.rowCount; + table.addRecords([[], []], 1, true); + table.startEditCell(3, 6); + return sheet; + }, + async verify(page) { + // 首条原记录保留在原位,第二条原记录下移两行。 + await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + if (table.rowCount !== window.__insertRowsBefore + 2 || + table.getCellValue(0, 1) !== 1 || table.getCellValue(0, 2) !== undefined || + table.getCellValue(0, 3) !== undefined || table.getCellValue(0, 4) !== 4) + throw new Error('Sheet 第二行插入位置或数值错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/insert-rows-at-start.mjs b/packages/vtable/__tests__/visual/cases/sheet/insert-rows-at-start.mjs new file mode 100644 index 0000000000..e6038058ba --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/insert-rows-at-start.mjs @@ -0,0 +1,45 @@ +/** 为列与行插入边界用例创建相同的匿名 Sheet 初始矩阵。 */ +function createInsertSheet(container) { + container.style.height = '400px'; + return new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, + sheets: [{ sheetKey: 'sheet1', sheetTitle: 'Sheet 1', rowCount: 200, columnCount: 10, + columns: [{ title: 'Values', width: 100 }], + data: [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], active: true }] + }); +} + +/** + * BugServer case IDs: 68ff243743ec0700a96ec5b8 + * 验证目的:公式回填与行高调整后,在第一条记录前插入两行。 + * 改写:使用匿名数值矩阵,保留插入位置与编辑动作。 + */ +export default { + mount(container) { + // 固定多区域公式结果,随后复现首行插入前的行高调整。 + const sheet = createInsertSheet(container); + const cell = { sheet: 'sheet1', row: 4, col: 3 }; + sheet.formulaManager.setCellContent(cell, '=SUM(A2:C2,B3,C3)'); + const result = sheet.formulaManager.getCellValue(cell); + if (result.error || result.value !== 17) throw new Error(`SUM 结果错误:${JSON.stringify(result)}`); + const table = sheet.getActiveSheet().tableInstance; + table.changeCellValue(3, 4, result.value, false, false); + table.stateManager.startResizeRow(1, 230, 50); + table.stateManager.updateResizeRow(390, 80); + table.stateManager.endResizeRow(); + window.__insertRowsBefore = table.rowCount; + table.addRecords([[], []], 0, true); + table.startEditCell(3, 6); + return sheet; + }, + async verify(page) { + // 两行空记录位于最前,首条原记录的数值下移两行。 + await page.evaluate(() => { + const table = window.__visualTable.getActiveSheet().tableInstance; + if (table.rowCount !== window.__insertRowsBefore + 2 || + table.getCellValue(0, 1) !== undefined || table.getCellValue(0, 2) !== undefined || + table.getCellValue(0, 3) !== 1 || table.getCellValue(0, 4) !== 4) + throw new Error('Sheet 首行插入位置或数值错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sheet/update-option.mjs b/packages/vtable/__tests__/visual/cases/sheet/update-option.mjs new file mode 100644 index 0000000000..ba1059758a --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sheet/update-option.mjs @@ -0,0 +1,35 @@ +/** + * BugServer case IDs: 6980594c7a558400611ce045 + * 验证目的:Sheet 的全量 updateOption 删除旧页、新增并激活新页、更新现有页数据。 + * 改写:删去与更新无关的菜单和插件,保留两个工作表与全量更新路径。 + */ +export default { + mount(container) { + // 从两个工作表切换为新页加更新后的第二页,重建公式依赖路径也会执行。 + const sheet = new window.VTableSheet.VTableSheet(container, { + showSheetTab: true, sheets: [ + { sheetKey: 'sheet1', sheetTitle: 'Sheet 1', rowCount: 20, columnCount: 10, + data: [[1, 2, 3], [4, 5, 6]], active: true }, + { sheetKey: 'sheet2', sheetTitle: 'Sheet 2', rowCount: 20, columnCount: 10, + data: [[2, 4, 6], [4, 5, 6]] } + ] + }); + sheet.updateOption({ showSheetTab: false, defaultRowHeight: 25, defaultColWidth: 100, + sheets: [ + { sheetKey: 'sheet12', sheetTitle: 'Sheet 12', rowCount: 20, columnCount: 10, + data: [[111, 211, 311], [4, 5, 6]], active: true }, + { sheetKey: 'sheet2', sheetTitle: 'Sheet 2', rowCount: 20, columnCount: 10, + data: [[222, 422, 622], [4, 5, 6]] } + ] }); + return sheet; + }, + async verify(page) { + // 旧页应删除,新增页激活,第二页数据同步为新值。 + await page.evaluate(() => { + const sheet = window.__visualTable; + if (sheet.getWorkSheetInstance('sheet1') || sheet.getActiveSheet()?.getKey() !== 'sheet12' || + !sheet.getWorkSheetInstance('sheet2') || !sheet.getActiveSheet().tableInstance) + throw new Error('Sheet 全量更新后的页结构错误'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sort/initial-desc.mjs b/packages/vtable/__tests__/visual/cases/sort/initial-desc.mjs new file mode 100644 index 0000000000..e5f8312f2c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sort/initial-desc.mjs @@ -0,0 +1,35 @@ +/** + * BugServer case IDs: 64745bc55d221c008666ee23 + * 验证目的:setRecords 的初始降序状态与自定义比较器共同排序。 + * 改写:缩减不影响排序的展示列,保留 name 排序事件的拦截回调。 + */ +export default { + mount(container) { + // 来源按 id 降序设置记录,并阻止 name 列的排序点击。 + const table = new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, + fieldFormat: record => `Completed ${record.progress}%` }, + { field: 'id', title: 'ID', width: 100, + sort: (a, b, order) => order === 'desc' ? b - a : a - b }, + { field: 'name', title: 'Name', width: 150, sort: true } + ], + records: [], widthMode: 'standard', allowFrozenColCount: 2 + }); + table.setRecords([ + { id: 1, progress: 100, name: 'A' }, { id: 2, progress: 80, name: 'B' }, + { id: 3, progress: 1, name: 'C' }, { id: 4, progress: 55, name: 'D' }, + { id: 5, progress: 28, name: 'E' } + ], { field: 'id', order: 'desc' }); + table.on('sort_click', args => { if (args.field === 'name') return false; }); + return table; + }, + async verify(page) { + // 直接检查可见首尾记录顺序。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellOriginValue(1, 1) !== 5 || table.getCellOriginValue(1, 5) !== 1) + throw new Error('初始降序未生效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sort/initial-multiple-state.mjs b/packages/vtable/__tests__/visual/cases/sort/initial-multiple-state.mjs new file mode 100644 index 0000000000..b26784b236 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sort/initial-multiple-state.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 66cd83fe5e11d900c8f0e96b + * 验证目的:先设置双字段降序,再 setRecords 后仍按两个字段排序。 + * 改写:将来源的人员字段改为编号与代号,保留两个排序键的并列数据。 + */ +export default { + mount(container) { + // 同一 score 内的 code 次序使第二排序键是否执行可观察。 + const table = new window.VTable.ListTable(container, { + columns: [ + { field: 'score', title: 'Score', sort: true, width: 140 }, + { field: 'code', title: 'Code', sort: true, width: 140 } + ], + multipleSort: true, + sortState: [{ field: 'score', order: 'desc' }, { field: 'code', order: 'desc' }], + widthMode: 'standard' + }); + table.setRecords([ + { score: 1, code: 'A' }, { score: 3, code: 'B' }, + { score: 3, code: 'C' }, { score: 2, code: 'D' } + ]); + return table; + }, + async verify(page) { + // 首字段降序后,同分组中的 C 必须排在 B 前。 + await page.evaluate(() => { + const table = window.__visualTable; + const codes = [1, 2, 3, 4].map(row => table.getCellValue(1, row)); + if (codes.join(',') !== 'C,B,D,A' || table.sortState?.length !== 2) + throw new Error(`初始复合排序错误:${JSON.stringify({ codes, sortState: table.sortState })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sort/multiple-header-click.mjs b/packages/vtable/__tests__/visual/cases/sort/multiple-header-click.mjs new file mode 100644 index 0000000000..9719b754e4 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sort/multiple-header-click.mjs @@ -0,0 +1,50 @@ +/** + * BugServer case IDs: 66cd81605e11d900c8f0e968 + * 验证目的:multipleSort 模式依次点击两个排序表头后保留两个排序状态。 + * 改写:保留重复 sortID 和两个点击动作,缩减无关格式化字段。 + */ +export default { + mount(container) { + // 重复 sortID 让第二排序字段有机会决定相邻记录的顺序。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'sortID', title: 'Sort ID', width: 150, sort: true }, + { field: 'name', title: 'Name', width: 150, sort: true }], + records: [{ id: 1, sortID: 6, name: 'a' }, { id: 2, sortID: 4, name: 'd' }, + { id: 3, sortID: 4, name: 'c' }, { id: 4, sortID: 2, name: 'a' }, + { id: 5, sortID: 3, name: 'b' }, { id: 6, sortID: 5, name: 'c' }], + multipleSort: true, widthMode: 'standard' + }); + }, + async exercise(page) { + // 点击两个真实表头排序图标,而不是直接写入 sortState。 + for (const col of [1, 2]) { + const point = await page.evaluate(col => { + // 图标位于标题内容右侧;按场景图实际边界定位,避免把列右边界当作图标。 + const cell = window.__visualTable.scenegraph.getCell(col, 0); + const stack = [cell]; + let icon; + while (stack.length && !icon) { + const mark = stack.pop(); + if (mark.attribute?.funcType === 'sort') icon = mark; + else stack.push(...(mark.children ?? [])); + } + if (!icon) throw new Error(`第 ${col} 列排序图标未渲染`); + const bounds = icon.globalAABBBounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (bounds.x1 + bounds.x2) / 2, y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }, col); + await page.mouse.move(point.x, point.y); + await page.mouse.click(point.x, point.y); + } + }, + async verify(page) { + // 两个真实点击后排序状态必须同时保留两个字段。 + await page.evaluate(() => { + const states = window.__visualTable.sortState; + if (!Array.isArray(states) || states.length !== 2 || + !states.some(state => state.field === 'sortID') || !states.some(state => state.field === 'name')) + throw new Error(`复合排序状态异常: ${JSON.stringify(states)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/sort/update-state.mjs b/packages/vtable/__tests__/visual/cases/sort/update-state.mjs new file mode 100644 index 0000000000..a95d43cedd --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/sort/update-state.mjs @@ -0,0 +1,24 @@ +/** + * BugServer case IDs: 65c086ce97cc3d008de5b482 + * 验证目的:setRecords 降序后通过 updateSortState 切换到升序。 + * 改写:仅保留排序列与固定数值,避免重复的展示字段。 + */ +export default { + mount(container) { + // 两次排序调用顺序与来源一致,结果首行应为最小值。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, sort: true }, + { field: 'name', title: 'Name', width: 150 }], widthMode: 'standard' + }); + table.setRecords([{ progress: 100, name: 'A' }, { progress: 80, name: 'B' }, + { progress: 1, name: 'C' }], { field: 'progress', order: 'desc' }); + table.updateSortState({ field: 'progress', order: 'asc' }); + return table; + }, + async verify(page) { + // 最终排序后的第一条记录须是数值 1。 + await page.evaluate(() => { + if (window.__visualTable.getCellValue(0, 1) !== 1) throw new Error('排序状态未更新'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/style/border-clip-direction.mjs b/packages/vtable/__tests__/visual/cases/style/border-clip-direction.mjs new file mode 100644 index 0000000000..c8fc2afe5d --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/style/border-clip-direction.mjs @@ -0,0 +1,27 @@ +/** + * BugServer case IDs: 66e28a3d0eae0a00f267b41e + * 验证目的:cellBorderClipDirection=bottom-right 与内边框同用时边线不断裂。 + * 改写:删除展示性说明,保留四边红色单元格边框和无外框条件。 + */ +export default { + mount(container) { + // 相邻多行让边框裁剪方向在截图中有可比差异。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, + fieldFormat: record => `${record.progress}%`, style: { textAlign: 'center', borderColor: ['red', 'red', 'red', 'red'] } }], + records: [100, 80, 1, 55, 28].map(progress => ({ progress })), + widthMode: 'standard', theme: { + frameStyle: { borderLineWidth: 0 }, cellInnerBorder: true, + cellBorderClipDirection: 'bottom-right' + } + }); + }, + async verify(page) { + // 最小值和最大值进入两端单元格,边线交给截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 1) !== '100%' || table.getCellValue(0, 5) !== '28%') + throw new Error('裁剪边框测试数据缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/style/dashed-border.mjs b/packages/vtable/__tests__/visual/cases/style/dashed-border.mjs new file mode 100644 index 0000000000..1940231eb0 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/style/dashed-border.mjs @@ -0,0 +1,23 @@ +/** + * BugServer case IDs: 65c1d09f97cc3d008de5b4bb + * 验证目的:单元格边框颜色数组与虚线样式同时绘制。 + * 改写:缩小记录和无关列,保留红字、双色边框和 [4,4] 线型。 + */ +export default { + mount(container) { + // 两条记录足以让虚线与相邻单元格边框进入截图。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, + style: { color: 'red', borderColor: ['red', 'green'], borderLineDash: [4, 4] } }, + { field: 'name', title: 'Name', width: 150 }], + records: [{ progress: 100, name: 'A' }, { progress: 80, name: 'B' }] + }); + }, + async verify(page) { + // 读取实际单元格样式,确认虚线配置没有被主题覆盖。 + await page.evaluate(() => { + const dash = window.__visualTable.getCellStyle(0, 1).borderLineDash; + if (!Array.isArray(dash) || dash.join(',') !== '4,4') throw new Error('虚线边框配置缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/style/underline.mjs b/packages/vtable/__tests__/visual/cases/style/underline.mjs new file mode 100644 index 0000000000..c0e4e60fca --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/style/underline.mjs @@ -0,0 +1,34 @@ +/** + * BugServer case IDs: 65e6dd18603de700d1d8ba05 + * 验证目的:普通、虚线和带偏移下划线与虚线边框共存。 + * 改写:保留四列差异及换行记录,修正来源 widthMode 拼写错误。 + */ +export default { + mount(container) { + // 同一行配置多种下划线参数,便于视觉比较细节。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, + style: { underline: true, color: 'red', borderColor: ['red', 'green'], borderLineDash: [4, 8] } }, + { field: 'id', title: 'ID', width: 100, + style: { underline: true, underlineDash: [4, 1], underlineOffset: 10, + color: 'red', borderColor: ['red', 'green'], borderLineDash: [8, 8] } }, + { field: 'note', title: 'Note', width: 150, + style: { autoWrapText: true, underline: true, underlineDash: [4, 1], underlineOffset: 10, + color: 'red', borderColor: ['red', 'green'], borderLineDash: [4, 8] } }, + { field: 'name', title: 'Name', width: 150, + style: { autoWrapText: true, underline: true, color: 'red', borderColor: ['red', 'green'], borderLineDash: [4, 8] } }], + records: [{ progress: 100, id: 1, note: 'Line 1\nLine 2', name: 'A\nB' }, + { progress: 80, id: 2, note: 'Short', name: 'C' }], + widthMode: 'standard', heightMode: 'autoHeight', enableLineBreak: true + }); + }, + async verify(page) { + // 四列样式都已配置,文字边界仍通过截图判定。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount !== 4 || table.getCellValue(2, 1) !== 'Line 1\nLine 2' || + table.options.columns[1].style.underlineOffset !== 10) + throw new Error('下划线样式场景无效'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/table/list-basic.mjs b/packages/vtable/__tests__/visual/cases/table/list-basic.mjs new file mode 100644 index 0000000000..eb6dd00c49 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/table/list-basic.mjs @@ -0,0 +1,24 @@ +/** + * 验证目的:列表表头和固定数据能正确创建、绘制与读取。 + * 来源:VTable 公共 ListTable API 独立样例。 + * 覆盖边界:不测试排序、编辑或虚拟滚动。 + */ +export default { + mount(container) { + // 固定记录与列配置,供双侧构建使用完全相同的输入。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Name', width: 150 }, { field: 'value', title: 'Value', width: 150 }], + records: [{ name: 'Alpha', value: 10 }, { name: 'Beta', value: 20 }] + }); + }, + async verify(page) { + // 同时检查数据语义和可见单元格位置,图片比较负责像素布局。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 1) !== 'Alpha' || table.getCellValue(1, 2) !== 20) + throw new Error('列表数据不正确'); + const rect = table.getCellRect(0, 1)?.bounds; + if (!rect || rect.x2 <= rect.x1 || rect.y2 <= rect.y1) throw new Error('没有有效单元格布局'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/table/list-select-cell.mjs b/packages/vtable/__tests__/visual/cases/table/list-select-cell.mjs new file mode 100644 index 0000000000..95c6044f27 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/table/list-select-cell.mjs @@ -0,0 +1,30 @@ +/** + * 验证目的:鼠标点击数据单元格后选择状态发生真实变化。 + * 来源:VTable 公共 ListTable API 独立样例。 + * 覆盖边界:不测试多选、拖选或键盘导航。 + */ +export default { + mount(container) { + // 创建两行数据,确保交互目标不依赖浏览器外部资源。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Name', width: 150 }, { field: 'value', title: 'Value', width: 150 }], + records: [{ name: 'Alpha', value: 10 }, { name: 'Beta', value: 20 }] + }); + }, + async exercise(page) { + // 根据公开单元格矩形定位目标,避免录制坐标在宿主尺寸变化后失效。 + const point = await page.evaluate(() => { + const bounds = window.__visualTable.getCellRect(1, 2).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (bounds.x1 + bounds.x2) / 2, y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 禁止仅凭截图或点击完成来宣称交互已生效。 + await page.waitForFunction(() => { + const range = window.__visualTable.getSelectedCellRanges()[0]; + return range?.start.col === 1 && range?.start.row === 2 && range?.end.col === 1 && range?.end.row === 2; + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/theme/arco.mjs b/packages/vtable/__tests__/visual/cases/theme/arco.mjs new file mode 100644 index 0000000000..5b8319ca66 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/theme/arco.mjs @@ -0,0 +1,29 @@ +/** + * BugServer case IDs: 647460d15d221c008666ee29 + * 验证目的:ARCO 主题与交叉悬停模式、自动行高共同渲染。 + * 改写:使用通用固定记录,保留主题和相关布局配置。 + */ +export default { + mount(container) { + // 主题配置由当前侧 VTable bundle 提供,避免外部资源。 + return new window.VTable.ListTable(container, { + columns: [ + { field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'name', title: 'Name', width: 150 } + ], + records: [{ progress: 100, id: 1, name: 'A' }, { progress: 80, id: 2, name: 'B' }], + showPin: true, widthMode: 'standard', allowFrozenColCount: 2, + theme: window.VTable.themes.ARCO, + hover: { highlightMode: 'cross', disableHeaderHover: true }, + heightMode: 'autoHeight', autoWrapText: true + }); + }, + async verify(page) { + // 验证主题表格完成数据渲染,颜色差异由截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(2, 1) !== 'A') throw new Error('ARCO 主题表格数据缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/theme/dark-icons.mjs b/packages/vtable/__tests__/visual/cases/theme/dark-icons.mjs new file mode 100644 index 0000000000..1f319b5662 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/theme/dark-icons.mjs @@ -0,0 +1,27 @@ +/** + * BugServer case IDs: 66f5028c885776011c598a60 + * 验证目的:DARK 主题下的排序、冻结和悬停图标保持可见。 + * 改写:保留 DARK、两列冻结、cross 悬停及自动高度,缩减展示字段。 + */ +export default { + mount(container) { + // 深色主题和图标相关配置保持在同一固定场景中。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100, sort: true }, + { field: 'name', title: 'Name', width: 150, sort: true }], + records: [100, 80, 1, 55, 28].map((progress, i) => ({ progress, id: i + 1, name: `Name ${i + 1}` })), + showPin: true, widthMode: 'standard', frozenColCount: 2, allowFrozenColCount: 3, + theme: window.VTable.themes.DARK, hover: { highlightMode: 'cross', disableHeaderHover: true }, + heightMode: 'autoHeight', autoWrapText: true + }); + }, + async verify(page) { + // 深色主题用例必须保留两列数据和冻结配置。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 1) !== 100 || table.frozenColCount !== 2) + throw new Error('深色主题冻结列缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/theme/update-theme.mjs b/packages/vtable/__tests__/visual/cases/theme/update-theme.mjs new file mode 100644 index 0000000000..518577b329 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/theme/update-theme.mjs @@ -0,0 +1,27 @@ +/** + * BugServer case IDs: 6567f9cc69022a32a0625120 + * 验证目的:创建 ARCO 主题列表后 updateTheme 立即更新画布底色。 + * 改写:缩减展示列,保留 cross 悬停、自动行高与 ARCO extends 更新顺序。 + */ +export default { + mount(container) { + // 先创建 ARCO,再覆盖 underlayBackgroundColor,避免仅测试初始主题。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'progress', title: 'Progress', width: 150, showSort: true }, + { field: 'id', title: 'ID', width: 100, sort: true }, { field: 'name', title: 'Name', width: 150 }], + records: [100, 80, 1, 55, 28].map((progress, i) => ({ progress, id: i + 1, name: `Name ${i + 1}` })), + theme: window.VTable.themes.ARCO, hover: { highlightMode: 'cross', disableHeaderHover: true }, + widthMode: 'standard', heightMode: 'autoHeight', autoWrapText: true + }); + table.updateTheme(window.VTable.themes.ARCO.extends({ underlayBackgroundColor: 'red' })); + return table; + }, + async verify(page) { + // 更新后数据仍可访问且主题覆盖被表格接收;具体底色由截图比较。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellValue(0, 1) !== 100 || table.theme.underlayBackgroundColor !== 'red') + throw new Error('updateTheme 未应用底色'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/transpose/only-body-auto-width.mjs b/packages/vtable/__tests__/visual/cases/transpose/only-body-auto-width.mjs new file mode 100644 index 0000000000..5ddfe5b044 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/transpose/only-body-auto-width.mjs @@ -0,0 +1,48 @@ +/** + * BugServer case IDs: 64b78beef1af57b472231cac, 65dc4029db5fe000ad96fd7f + * 验证目的:转置表的 only-body 自动列宽不被很长的行头标题撑大。 + * 改写:来源的人员字段和图标替换为两个匿名字段与短记录。 + */ +export default { + mount(container) { + // 标准模式与 only-body 模式共享长标题,用实测行头列宽比较。 + container.style.width = '800px'; + container.style.height = '320px'; + container.style.display = 'grid'; + container.style.gridTemplateColumns = '1fr 1fr'; + const options = { + columns: [ + { field: 'a', title: 'A very long header label for the first field', width: 'auto' }, + { field: 'b', title: 'Another long header label', width: 'auto' } + ], + records: [{ a: 'X', b: 'Y' }, { a: 'M', b: 'N' }], + transpose: true, widthMode: 'autoWidth', defaultHeaderColWidth: 80 + }; + const tables = ['normal', 'only-body'].map(columnWidthComputeMode => { + const host = document.createElement('div'); + host.style.cssText = 'width:390px;height:310px'; + container.append(host); + return new window.VTable.ListTable(host, { ...options, columnWidthComputeMode }); + }); + return { + tables, + async renderAsync() { + // 等待转置表自动宽度计算完成。 + await Promise.all(tables.map(table => table.renderAsync?.())); + }, + release() { + // 两张对照表分别释放。 + tables.forEach(table => table.release()); + } + }; + }, + async verify(page) { + // only-body 的首列应保持默认行头宽度,normal 则受长标题撑宽。 + await page.evaluate(() => { + const [normal, body] = window.__visualTable.tables; + const widths = [normal.getColWidth(0), body.getColWidth(0)]; + if (widths[0] <= widths[1] + 80 || widths[1] > 100) + throw new Error(`转置 only-body 列宽异常:${JSON.stringify(widths)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/transpose/theme-header.mjs b/packages/vtable/__tests__/visual/cases/transpose/theme-header.mjs new file mode 100644 index 0000000000..6a08a6a39d --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/transpose/theme-header.mjs @@ -0,0 +1,39 @@ +/** + * BugServer case IDs: 65d599b29c176100a02dd0a4 + * 验证目的:转置列表中分组表头和扩展主题边框共同绘制。 + * 改写:人物联系方式替换为固定通用文本,保留转置、嵌套列及主题扩展结构。 + */ +export default { + mount(container) { + // 使用三个公开示例字段和多行记录,避免私人联系方式进入仓库。 + return new window.VTable.ListTable(container, { + records: [ + { id: 1, first: 'A', last: 'One', city: 'North' }, + { id: 2, first: 'B', last: 'Two', city: 'South' }, + { id: 3, first: 'C', last: 'Three', city: 'East' } + ], + columns: [ + { field: 'id', title: 'ID', sort: true, width: 'auto' }, + { title: 'Full name', columns: [ + { field: 'first', title: 'First name' }, { field: 'last', title: 'Last name' } + ] }, + { field: 'city', title: 'City' } + ], + widthMode: 'standard', transpose: true, dragHeaderMode: 'all', limitMinWidth: 20, + theme: window.VTable.themes.DEFAULT.extends({ + headerStyle: { color: 'red', frameStyle: { borderColor: 'red', borderLineWidth: 2 } } + }) + }); + }, + async verify(page) { + // 转置后列数随记录数增长,且表格保留原有字段值。 + await page.evaluate(() => { + const table = window.__visualTable; + const values = []; + for (let row = 0; row < table.rowCount; row++) + for (let col = 0; col < table.colCount; col++) values.push(table.getCellValue(col, row)); + if (!table.options.transpose || !values.includes('Three') || !values.includes('South')) + throw new Error('转置数据或布局缺失'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/transpose/update-option-off.mjs b/packages/vtable/__tests__/visual/cases/transpose/update-option-off.mjs new file mode 100644 index 0000000000..8a0e064ee5 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/transpose/update-option-off.mjs @@ -0,0 +1,26 @@ +/** + * BugServer case IDs: 65a60e2cc2cd3f0ae258d5e5 + * 验证目的:先以转置模式设定 100 条记录,再用 updateOption 关闭转置。 + * 改写:保留操作顺序和记录规模,移除无关复杂 SVG 与日期字段。 + */ +export default { + mount(container) { + // 转置状态下先设置记录,再复用配置对象切回普通表格。 + const option = { columns: [{ field: 'progress', title: 'Progress', width: 120 }, + { field: 'id', title: 'ID', width: 100 }, { field: 'name', title: 'Name', width: 150 }], + widthMode: 'standard', transpose: true, hover: { highlightMode: 'cross' } }; + const table = new window.VTable.ListTable(container, option); + table.setRecords(Array.from({ length: 100 }, (_, i) => ({ progress: i, id: i + 1, name: `Name ${i + 1}` }))); + option.transpose = false; + table.updateOption(option); + return table; + }, + async verify(page) { + // 更新后必须恢复三列一百行的普通表格。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.colCount !== 3 || table.rowCount !== 101 || table.getCellValue(1, 1) !== 1) + throw new Error('updateOption 未关闭转置'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/checkbox-add-record.mjs b/packages/vtable/__tests__/visual/cases/tree/checkbox-add-record.mjs new file mode 100644 index 0000000000..f997814af6 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/checkbox-add-record.mjs @@ -0,0 +1,40 @@ +/** + * BugServer case IDs: 6889e5b0b485f100aabeadee + * 验证目的:树形列表的行序号复选状态在头部插入记录后仍可见。 + * 改写:商品层级与金额换成通用节点,保留三级树、懒加载节点与 addRecord。 + */ +export default { + mount(container) { + // 先设置原有复选状态,再在根记录索引 0 插入空记录。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'name', cellType: 'checkbox', tree: true, title: 'Group', width: 190 }, + { field: 'value', title: 'Value', width: 120 }], + records: [{ name: 'Group A', value: 100, children: [ + { name: 'A1', value: 60, children: [{ name: 'A1a', value: 30 }, { name: 'A1b', value: 30 }] }, + { name: 'A2', value: 40 } + ] }, { name: 'Group B', value: 80, children: [{ name: 'B1', value: 80 }] }, + { name: 'Lazy group', value: 20, children: true }], + hierarchyIndent: 20, hierarchyExpandLevel: 2, enableCheckboxCascade: false, + // 行序号列采用无文字复选框,以保持原来源的状态迁移条件。 + rowSeriesNumber: { width: 50, cellType: 'checkbox', format: () => '' }, + select: { disableDragSelect: true }, theme: window.VTable.themes.BRIGHT + }); + table.setCellCheckboxState(0, 1, true); + table.setCellCheckboxState(0, 2, true); + table.setCellCheckboxState(0, 5, true); + window.__treeRowsBefore = table.rowCount; + table.addRecord({}, 0); + return table; + }, + async verify(page) { + // 新增根记录后行数应增加,原已选复选框仍有可见状态。 + await page.evaluate(() => { + const table = window.__visualTable; + let checked = 0; + for (let row = 1; row < table.rowCount; row++) + if (table.getCellCheckboxState(0, row) === true) checked++; + if (table.rowCount <= window.__treeRowsBefore || checked < 1) + throw new Error(`树记录插入后复选状态错误:rows=${table.rowCount}, checked=${checked}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/checkbox-delete-record.mjs b/packages/vtable/__tests__/visual/cases/tree/checkbox-delete-record.mjs new file mode 100644 index 0000000000..2cb15ee129 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/checkbox-delete-record.mjs @@ -0,0 +1,43 @@ +/** + * BugServer case IDs: 6889e710e565f800a58eb402 + * 验证目的:树形列表同时删除根节点及子节点后,行序号复选状态与树结构仍可用。 + * 改写:用通用节点替换商品和金额文字,保留 deleteRecords 的混合路径参数。 + */ +export default { + mount(container) { + // 多层树里预设复选状态,然后删除第一个根和第二根的首个子节点。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'name', cellType: 'checkbox', tree: true, title: 'Group', width: 190 }, + { field: 'value', title: 'Value', width: 120 }], + records: [{ name: 'Group A', value: 100, children: [ + { name: 'A1', value: 60, children: [{ name: 'A1a', value: 30 }, { name: 'A1b', value: 30 }] }, + { name: 'A2', value: 40 } + ] }, { name: 'Group B', value: 80, children: [ + { name: 'B1', value: 40 }, { name: 'B2', value: 40 } + ] }, { name: 'Group C', value: 70, children: [{ name: 'C1', value: 70 }] }, + { name: 'Lazy group', value: 20, children: true }], + hierarchyIndent: 20, hierarchyExpandLevel: 2, enableCheckboxCascade: false, + // 行序号空文字复选框保留来源的状态场景。 + rowSeriesNumber: { width: 50, cellType: 'checkbox', format: () => '' }, + select: { disableDragSelect: true }, theme: window.VTable.themes.BRIGHT + }); + table.setCellCheckboxState(1, 3, true); + table.setCellCheckboxState(0, 1, true); + table.setCellCheckboxState(0, 2, true); + table.setCellCheckboxState(0, 6, true); + window.__treeRowsBefore = table.rowCount; + table.deleteRecords([0, [1, 0]]); + return table; + }, + async verify(page) { + // 混合层级删除应缩短可见树,剩余行仍可读取复选状态。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.rowCount >= window.__treeRowsBefore || !table.records.some(record => record.name === 'Group C')) + throw new Error(`树记录删除失败:${window.__treeRowsBefore} -> ${table.rowCount}`); + const states = Array.from({ length: table.rowCount - 1 }, (_, index) => table.getCellCheckboxState(0, index + 1)); + if (!states.some(state => state === true)) + throw new Error(`删除后已选行丢失:${JSON.stringify(states)};records=${JSON.stringify(table.records)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/checkbox-record-index-clear.mjs b/packages/vtable/__tests__/visual/cases/tree/checkbox-record-index-clear.mjs new file mode 100644 index 0000000000..6ebf296f12 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/checkbox-record-index-clear.mjs @@ -0,0 +1,59 @@ +/** + * BugServer case IDs: 6a68957b3dd664005e729649 + * 验证目的:折叠树的隐藏子记录可按原始索引取消勾选,再清空整列复选状态。 + * 改写:来源的项目人员改为匿名分组,保留两次点击触发对应 API。 + */ +export default { + mount(container) { + // 隐藏子节点保持初始选中,两个普通操作列单元格触发状态 API。 + const table = new window.VTable.ListTable(container, { + columns: [ + { field: 'item', title: 'Item', tree: true, cellType: 'checkbox', headerType: 'checkbox', width: 230 }, + { field: 'action', title: 'Action', width: 260 } + ], + records: [ + { item: { text: 'Group A', checked: true }, action: 'Uncheck hidden child', + hierarchyState: 'collapse', children: [ + { item: { text: 'Child A1', checked: true }, action: 'Hidden' }, + { item: { text: 'Child A2', checked: true }, action: 'Hidden' } + ] }, + { item: { text: 'Group B', checked: true }, action: 'Clear all' } + ], + enableCheckboxCascade: false, enableHeaderCheckboxCascade: false, + hierarchyIndent: 20, defaultRowHeight: 38 + }); + table.on('click_cell', ({ col, row }) => { + // 用户点击操作列后分别调用隐藏记录索引和整列清除 API。 + if (col === 1 && row === 1) table.setCellCheckboxStateByRecordIndex([0, 0], 'item', false); + if (col === 1 && row === 2) table.clearAllCheckboxState('item'); + }); + return table; + }, + async exercise(page) { + // 第一次点击后记录隐藏子节点状态,再点击第二行清除全部状态。 + const points = await page.evaluate(() => { + const table = window.__visualTable; + const host = document.getElementById('table').getBoundingClientRect(); + return [1, 2].map(row => { + const b = table.getCellRect(1, row).bounds; + return { x: host.x + (b.x1 + b.x2) / 2, y: host.y + (b.y1 + b.y2) / 2 }; + }); + }); + await page.mouse.click(points[0].x, points[0].y); + await page.evaluate(() => { + // 使用原始树路径检查隐藏子节点,不依赖它是否出现在当前可视行。 + const checked = window.__visualTable.stateManager.checkedState; + if (checked.get('0,0')?.item !== false || checked.get('0')?.item !== true) + throw new Error(`隐藏子节点状态异常:${JSON.stringify([...checked])}`); + }); + await page.mouse.click(points[1].x, points[1].y); + }, + async verify(page) { + // 两次点击后,所有树节点的复选状态都应已清空。 + await page.evaluate(() => { + const checked = window.__visualTable.stateManager.checkedState; + if ([...checked.values()].some(state => state.item === true)) + throw new Error(`整列复选状态未清空:${JSON.stringify([...checked])}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/checkbox-set-state.mjs b/packages/vtable/__tests__/visual/cases/tree/checkbox-set-state.mjs new file mode 100644 index 0000000000..0c8899bcd0 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/checkbox-set-state.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 6889ea68eb9ae000b36719b2 + * 验证目的:树列表中对第三级节点设置复选状态,且不级联到父节点。 + * 改写:分类与数值改为通用数据,保留树层级、行序号复选框与 setCellCheckboxState(1,3,true)。 + */ +export default { + mount(container) { + // 展开两层树并关闭级联,再设置原始位置的子节点复选框。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'category', cellType: 'checkbox', tree: true, title: 'Category', width: 190 }, + { field: 'amount', title: 'Amount', width: 120 }], + records: [{ category: 'Group A', amount: 100, children: [ + { category: 'Subgroup A1', amount: 60, children: [ + { category: 'Leaf A', amount: 30 }, { category: 'Leaf B', amount: 30 }] }, + { category: 'Subgroup A2', amount: 40 } ] }, + { category: 'Group B', amount: 80, children: [{ category: 'Subgroup B1', amount: 80 }] }, + { category: 'Lazy group', amount: 20, children: true }], + hierarchyIndent: 20, hierarchyExpandLevel: 2, enableCheckboxCascade: false, + rowSeriesNumber: { width: 50, cellType: 'checkbox', format: () => '' }, + select: { disableDragSelect: true }, theme: window.VTable.themes.BRIGHT + }); + table.setCellCheckboxState(1, 3, true); + return table; + }, + async verify(page) { + // 第三级节点已选中,未发生向父节点级联。 + await page.evaluate(() => { + const table = window.__visualTable; + if (table.getCellCheckboxState(1, 3) !== true || table.getCellCheckboxState(1, 2) === true) + throw new Error('树节点复选状态或级联不符合预期'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/filter-clear-lazy.mjs b/packages/vtable/__tests__/visual/cases/tree/filter-clear-lazy.mjs new file mode 100644 index 0000000000..1ac4b7e5eb --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/filter-clear-lazy.mjs @@ -0,0 +1,51 @@ +/** + * BugServer case IDs: 66053ddec302f600a46921a1, 66053eb3a8fccc00acd37033 + * 验证目的:树形列表的过滤、懒加载子项和清除过滤依次保持正确层级。 + * 改写:商品层级替换为匿名节点,保留高低数值过滤及清除动作。 + */ +export default { + mount(container) { + // 首个父项的子节点由展开事件提供,另一个父项被高分过滤排除。 + container.style.height = '400px'; + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'label', title: 'Node', width: 220, tree: true }, + { field: 'score', title: 'Score', width: 120 }], + records: [{ label: 'Group A', score: 90, children: true }, + { label: 'Group B', score: 10, children: [{ label: 'B1', score: 5 }] }], + hierarchyExpandLevel: 1, hierarchyIndent: 20 + }); + table.on(window.VTable.ListTable.EVENT_TYPE.TREE_HIERARCHY_STATE_CHANGE, args => { + if (args.hierarchyState === window.VTable.TYPES.HierarchyState.expand && + !Array.isArray(args.originData.children)) + table.setRecordChildren([{ label: 'A1', score: 70 }, + { label: 'A2', score: 20 }], args.col, args.row); + }); + return table; + }, + async exercise(page) { + // 先确认过滤减少可见行,再展开留下的懒加载节点并清除规则。 + await page.evaluate(() => { + const table = window.__visualTable; + window.__treeRowsBefore = table.rowCount; + table.updateFilterRules([{ filterFunc: record => record.score > 50 }]); + window.__treeRowsFiltered = table.rowCount; + }); + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(0, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + b.x1 + 23, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + await page.evaluate(() => window.__visualTable.updateFilterRules()); + }, + async verify(page) { + // 清除后原节点和懒加载子项均可见,行数回升。 + await page.evaluate(() => { + const table = window.__visualTable; + const labels = Array.from({ length: table.rowCount }, (_, row) => table.getCellValue(0, row)); + if (window.__treeRowsFiltered >= window.__treeRowsBefore || + !labels.includes('Group B') || !labels.includes('A1')) + throw new Error(`树过滤清除或懒加载失败:${JSON.stringify(labels)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/lazy-record-children.mjs b/packages/vtable/__tests__/visual/cases/tree/lazy-record-children.mjs new file mode 100644 index 0000000000..564bb064f8 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/lazy-record-children.mjs @@ -0,0 +1,51 @@ +/** + * BugServer case IDs: 656701ec4c3611faee9b19db + * 验证目的:点击懒加载树节点后,在层级事件中 setRecordChildren 并显示新子节点。 + * 改写:商品分类与金额替换成通用分组,保留 children:true 与真实展开点击。 + */ +export default { + mount(container) { + // 初始懒加载节点无数组 children,展开事件负责补入固定子记录。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Group', tree: true, width: 200, sort: true }, + { field: 'value', title: 'Value', width: 120, sort: true }], + records: [{ name: 'Group A', value: 10, children: [ + { name: 'A1', value: 5 }, { name: 'A2', value: 5 } + ] }, { name: 'Lazy group', value: 20, children: true }, + { name: 'Group B', value: 30, children: [{ name: 'B1', value: 30 }] }], + hierarchyIndent: 20, hierarchyExpandLevel: 2, allowFrozenColCount: 2, + showPin: true, theme: window.VTable.themes.BRIGHT, defaultRowHeight: 32 + }); + table.on(window.VTable.ListTable.EVENT_TYPE.TREE_HIERARCHY_STATE_CHANGE, args => { + // 仅对首次展开的占位节点补入子行,避免重复展开时覆盖数据。 + if (args.hierarchyState !== window.VTable.TYPES.HierarchyState.expand || + Array.isArray(args.originData.children)) return; + table.setRecordChildren([{ name: 'Lazy child A', value: 7 }, + { name: 'Lazy child B', value: 13 }], args.col, args.row); + }); + return table; + }, + async exercise(page) { + // 用单元格位置定位懒加载行的层级图标,避免依赖录制时的固定像素。 + const point = await page.evaluate(() => { + const table = window.__visualTable; + let row = 1; + while (row < table.rowCount && table.getCellValue(0, row) !== 'Lazy group') row++; + if (row === table.rowCount) throw new Error('懒加载节点缺失'); + const bounds = table.getCellRect(0, row).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + bounds.x1 + 22, y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 异步式子节点注入必须改变源记录和可见表体。 + await page.waitForFunction(() => Array.isArray(window.__visualTable.records[1].children)); + await page.evaluate(() => { + const table = window.__visualTable; + const values = Array.from({ length: table.rowCount - 1 }, (_, index) => table.getCellValue(0, index + 1)); + if (!values.includes('Lazy child A') || !values.includes('Lazy child B')) + throw new Error(`懒加载子节点未显示:${values.join(', ')}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/list-merge-expand.mjs b/packages/vtable/__tests__/visual/cases/tree/list-merge-expand.mjs new file mode 100644 index 0000000000..19ef38b28c --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/list-merge-expand.mjs @@ -0,0 +1,33 @@ +/** + * BugServer case IDs: 6613d5fb2acd9c00d1c98a3f + * 验证目的:树形列表中相邻值合并后,点击层级图标仍能展开子行。 + * 改写:层级标识和日期列替换为通用分组;保留树、合并列与录制点击。 + */ +export default { + mount(container) { + // 两个父节点各有子行,合并列的数据保持相邻重复。 + return new window.VTable.ListTable(container, { + columns: [{ field: 'item', title: 'Item', width: 260, tree: true }, + { field: 'value', title: 'Value', width: 180, mergeCell: true }], + records: [{ item: 'Group A', value: 1, children: [{ item: 'A1', value: 1 }] }, + { item: 'Group B', value: 2, children: [{ item: 'B1', value: 2 }] }], + frozenColCount: 1, defaultRowHeight: 30, heightMode: 'autoHeight', autoWrapText: true + }); + }, + async exercise(page) { + // 点击首行左侧的层级图标,复现来源录制的展开动作。 + const point = await page.evaluate(() => { + const b = window.__visualTable.getCellRect(0, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + b.x1 + 22, y: host.y + (b.y1 + b.y2) / 2 }; + }); + await page.mouse.click(point.x, point.y); + }, + async verify(page) { + // 首个节点应变成展开状态,子行应加入可见行。 + await page.waitForFunction(() => window.__visualTable.getHierarchyState(0, 1) === 'expand'); + await page.evaluate(() => { + if (window.__visualTable.rowCount < 4) throw new Error('树形子行未展开'); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/pagination-double-click.mjs b/packages/vtable/__tests__/visual/cases/tree/pagination-double-click.mjs new file mode 100644 index 0000000000..ad8ddba63b --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/pagination-double-click.mjs @@ -0,0 +1,57 @@ +/** + * BugServer case IDs: 67371af030877400b3e630f6 + * 验证目的:树形列表在分页第二页双击表体单元格时,事件仍对应当前可见记录。 + * 改写:商品层级与数字替换为通用分组,保留分页、树节点与录制的双击动作。 + */ +export default { + mount(container) { + // 每页两条根记录,第二页包含普通树和懒加载占位节点。 + const table = new window.VTable.ListTable(container, { + columns: [{ field: 'name', title: 'Group', tree: true, width: 200, sort: true }, + { field: 'value', title: 'Value', width: 120, sort: true }], + records: [ + { name: 'Group A', value: 10, children: [{ name: 'A1', value: 10 }] }, + { name: 'Group B', value: 20, children: [{ name: 'B1', value: 20 }] }, + { name: 'Group C', value: 30, children: [{ name: 'C1', value: 30 }] }, + { name: 'Lazy group', value: 40, children: true } + ], + pagination: { perPageCount: 2, currentPage: 1 }, + hierarchyIndent: 20, hierarchyExpandLevel: 2, + showPin: true, allowFrozenColCount: 2, theme: window.VTable.themes.BRIGHT, + defaultRowHeight: 32 + }); + window.__treePageDoubleClicks = []; + table.on(window.VTable.ListTable.EVENT_TYPE.DBLCLICK_CELL, event => { + // 记录双击事件的行列位置与可见值,确认没有映射到第一页。 + window.__treePageDoubleClicks.push({ col: event.col, row: event.row, + value: table.getCellValue(event.col, event.row) }); + }); + table.on(window.VTable.ListTable.EVENT_TYPE.TREE_HIERARCHY_STATE_CHANGE, args => { + // 来源允许对分页中的懒加载占位行动态补充子记录。 + if (args.hierarchyState === window.VTable.TYPES.HierarchyState.expand && + !Array.isArray(args.originData.children)) + table.setRecordChildren([{ name: 'Lazy child', value: 40 }], args.col, args.row); + }); + return table; + }, + async exercise(page) { + // 双击第二页首条记录的数据列,重放来源中的连续点击。 + const point = await page.evaluate(() => { + const bounds = window.__visualTable.getCellRect(1, 1).bounds; + const host = document.getElementById('table').getBoundingClientRect(); + return { x: host.x + (bounds.x1 + bounds.x2) / 2, + y: host.y + (bounds.y1 + bounds.y2) / 2 }; + }); + await page.mouse.dblclick(point.x, point.y, { delay: 80 }); + await page.waitForFunction(() => window.__treePageDoubleClicks.length > 0); + }, + async verify(page) { + // 事件值必须来自第二页当前可见的记录,并且树结构仍在。 + await page.evaluate(() => { + const table = window.__visualTable; + const event = window.__treePageDoubleClicks[0]; + if (!event || event.value !== 30 || table.getCellValue(0, 1) !== 'Group C') + throw new Error(`树分页双击映射错误:${JSON.stringify(event)}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/cases/tree/zero-height-toggle.mjs b/packages/vtable/__tests__/visual/cases/tree/zero-height-toggle.mjs new file mode 100644 index 0000000000..5ca7532d78 --- /dev/null +++ b/packages/vtable/__tests__/visual/cases/tree/zero-height-toggle.mjs @@ -0,0 +1,61 @@ +/** + * BugServer case IDs: 6a69af4d02c575005f77ab2c + * 验证目的:过滤后高度为零的树子行在折叠与展开后不阻止可见行填满视口。 + * 改写:来源的 300 个隐藏行及 40 个可见行缩为固定匿名分组。 + */ +export default { + mount(container) { + // 第一组可在过滤后隐藏子行,第二组保留足够多的可见行填满视口。 + container.style.width = '720px'; + container.style.height = '360px'; + const hiddenChildren = Array.from({ length: 24 }, (_, index) => ({ + name: `Hidden ${index + 1}`, visible: true + })); + const records = [ + { name: 'Filtered group', visible: true, hierarchyState: 'expand', children: hiddenChildren }, + { name: 'Visible group', visible: true, hierarchyState: 'expand', + children: Array.from({ length: 12 }, (_, index) => ({ name: `Visible ${index + 1}`, visible: true })) } + ]; + const options = { + columns: [{ field: 'name', title: 'Name', tree: true, width: 260 }], + records, defaultRowHeight: 40, hierarchyExpandLevel: 2, + customComputeRowHeight({ row, table }) { + // 已过滤的原始记录占零高度,其他树行维持固定高度。 + return table.getCellOriginRecord(0, row)?.visible === false ? 0 : 40; + } + }; + const table = new window.VTable.ListTable(container, options); + return { + table, hiddenChildren, options, + async renderAsync() { + // 等待树形表初始绘制。 + await table.renderAsync?.(); + }, + release() { + // 清理树形表实例。 + table.release(); + } + }; + }, + async exercise(page) { + // 标记子记录不可见、清空行高缓存并重复折叠展开首个分组。 + await page.evaluate(async () => { + const fixture = window.__visualTable; + fixture.hiddenChildren.forEach(child => { child.visible = false; }); + await fixture.table.updateOption(fixture.options, { clearRowHeightCache: true, clearColWidthCache: false }); + fixture.table.toggleHierarchyState(0, 1, false); + fixture.table.toggleHierarchyState(0, 1, false); + }); + }, + async verify(page) { + // 第一隐藏行仍为零高,场景图已把后续可见行拉进可视区域。 + await page.evaluate(() => { + const table = window.__visualTable.table; + const bodyHeight = table.tableNoFrameHeight - table.getFrozenRowsHeight() - table.getBottomFrozenRowsHeight(); + const renderedHeight = table.getRowsHeight(table.frozenRowCount, table.scenegraph.proxy.rowEnd); + const hiddenHeight = table.getRowHeight(table.columnHeaderLevelCount + 1); + if (hiddenHeight !== 0 || renderedHeight < bodyHeight) + throw new Error(`零高度树行未正确处理:${JSON.stringify({ hiddenHeight, renderedHeight, bodyHeight })}`); + }); + } +}; diff --git a/packages/vtable/__tests__/visual/page.html b/packages/vtable/__tests__/visual/page.html new file mode 100644 index 0000000000..c8ea975069 --- /dev/null +++ b/packages/vtable/__tests__/visual/page.html @@ -0,0 +1,53 @@ + + + + + + + VTable visual regression + + + +
+ + + diff --git a/packages/vtable/__tests__/visual/playwright.config.mjs b/packages/vtable/__tests__/visual/playwright.config.mjs new file mode 100644 index 0000000000..17caa4b118 --- /dev/null +++ b/packages/vtable/__tests__/visual/playwright.config.mjs @@ -0,0 +1,37 @@ +import path from 'node:path'; +import { defineConfig } from '@playwright/test'; +import { settings } from './settings.mjs'; + +const runDir = process.env.VISUAL_RUN_DIR; +const phase = process.env.VISUAL_PHASE; +if (!runDir || !['baseline', 'current'].includes(phase)) throw new Error('请通过 visual-test.mjs 运行'); + +export default defineConfig({ + testDir: '.', + testMatch: 'visual.spec.mjs', + workers: 1, + retries: 0, + timeout: settings.timeout, + outputDir: path.join(runDir, `${phase}-results`), + snapshotPathTemplate: path.join(runDir, 'snapshots', '{arg}{ext}'), + updateSnapshots: phase === 'baseline' ? 'all' : 'none', + expect: { timeout: 5000, toMatchSnapshot: settings.comparison }, + reporter: [ + ['line'], + ['html', { outputFolder: path.join(runDir, `${phase}-report`), open: 'never' }], + ['json', { outputFile: path.join(runDir, `${phase}-playwright.json`) }], + ['./reporter.mjs'] + ], + use: { + browserName: 'chromium', + headless: true, + viewport: settings.viewport, + deviceScaleFactor: settings.deviceScaleFactor, + locale: settings.locale, + timezoneId: settings.timezoneId, + colorScheme: 'light', + reducedMotion: 'reduce', + serviceWorkers: 'block', + trace: 'retain-on-failure' + } +}); diff --git a/packages/vtable/__tests__/visual/report.mjs b/packages/vtable/__tests__/visual/report.mjs new file mode 100644 index 0000000000..b485945b20 --- /dev/null +++ b/packages/vtable/__tests__/visual/report.mjs @@ -0,0 +1,442 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** 转义报告中的动态文本,同时用于 HTML 内容和属性。 */ +function escape(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +/** 复现参数以单引号包裹,避免目录或报告文字被 shell 解释。 */ +function quote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +/** 人与 Agent 共用范围说明;未选用例不被描述为已通过。 */ +function selectionDescription(report) { + const selection = report.selection; + if (!selection) return `范围未记录;本次记录 ${report.cases.length} 个用例`; + const label = + selection.mode === 'all' + ? '全部本地用例' + : `${selection.mode === 'directory' ? '目录' : '单用例'} ${selection.value}`; + return `${label};选中 ${selection.selectedCount} / ${selection.totalCount};其余 ${ + selection.totalCount - selection.selectedCount + } 个未执行`; +} + +/** 将已存在的运行内文件转换为可搬迁的相对路径,拒绝越界引用。 */ +async function artifact(runDir, relative) { + if (!relative || path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..')) return null; + try { + return (await fs.stat(path.join(runDir, relative))).isFile() ? relative : null; + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } +} + +/** 汇合两阶段结果;缺少图片或未完成比较不能被解释为通过。 */ +export async function saveReport(runDir, summary) { + const phases = {}; + const reportStarted = performance.now(); + const issues = [...(summary.issues ?? [])]; + const selection = summary.selection; + if ( + selection && + (!['all', 'directory', 'case'].includes(selection.mode) || + !Number.isSafeInteger(selection.totalCount) || + selection.totalCount < summary.cases.length || + selection.selectedCount !== summary.cases.length || + !Array.isArray(selection.selectedIds) || + selection.selectedIds.length !== summary.cases.length || + new Set(selection.selectedIds).size !== summary.cases.length || + summary.cases.some(item => !selection.selectedIds.includes(item.id)) || + (selection.mode === 'all' && (selection.value !== null || selection.totalCount !== selection.selectedCount)) || + (selection.mode !== 'all' && typeof selection.value !== 'string') || + (selection.mode === 'case' && (selection.selectedCount !== 1 || selection.selectedIds[0] !== selection.value))) + ) + issues.push({ + phase: 'report', + category: 'execution', + code: 'RESULT_SET_MISMATCH', + message: '报告范围与选中用例集合不一致' + }); + for (const phase of ['baseline', 'current']) { + phases[phase] = summary[`${phase}Result`]; + if (!phases[phase]) { + try { + phases[phase] = JSON.parse(await fs.readFile(path.join(runDir, `${phase}-result.json`), 'utf8')); + } catch (error) { + if (error.code !== 'ENOENT') + issues.push({ phase, category: 'report', code: 'REPORT_FAILED', message: String(error) }); + } + } + for (const message of phases[phase]?.errors ?? []) + issues.push({ phase, category: 'execution', code: 'EXECUTION_FAILED', message }); + } + if (summary.error) + issues.push({ phase: summary.stage ?? 'preparation', category: 'execution', message: summary.error }); + if (summary.cleanupError) issues.push({ phase: 'cleanup', category: 'execution', message: summary.cleanupError }); + for (const [phase, result] of Object.entries(phases)) { + if (!result) continue; + const expected = summary.cases.map(item => item.id); + const actual = result.tests.map(item => item.id); + if ( + actual.length !== expected.length || + new Set(actual).size !== actual.length || + actual.some(id => !expected.includes(id)) + ) + issues.push({ phase, category: 'execution', code: 'RESULT_SET_MISMATCH', message: '阶段结果与选择集不一致' }); + if (result.finalized === false) + issues.push({ phase, category: 'execution', code: 'RUN_INCOMPLETE', message: '阶段异常结束,保留已完成用例' }); + } + // 预检失败时尚未解析基线,复现仍应保留用户请求的 SHA 或自比较模式。 + const baselineSha = summary.baseline?.sha ?? summary.request?.baseline; + const baselineArg = + summary.baseline?.repository === 'working-tree' || summary.request?.['self-compare'] + ? '--self-compare' + : baselineSha + ? `--baseline ${baselineSha}` + : ''; + const cases = []; + for (const item of summary.cases) { + const baseline = phases.baseline?.tests.find(test => test.id === item.id); + const current = phases.current?.tests.find(test => test.id === item.id); + const errors = []; + const images = { baseline: null, current: null, diff: null }; + for (const [phase, result] of [ + ['baseline', baseline], + ['current', current] + ]) { + for (const message of result?.errors ?? []) { + errors.push({ + phase, + stage: result.stage ?? 'execution', + category: result.status === 'diff' ? 'visual_difference' : result.category ?? 'execution', + code: result.code ?? (result.status === 'diff' ? 'VISUAL_DIFFERENCE' : 'EXECUTION_FAILED'), + message + }); + } + images[phase] = await artifact(runDir, result?.attachments?.find(file => file.name === 'rendered')?.path); + if (['passed', 'diff'].includes(result?.status) && !images[phase]) { + errors.push({ + phase, + stage: 'report', + category: 'missing_artifact', + code: 'MISSING_ARTIFACT', + message: '缺少最终截图' + }); + } + } + images.diff = await artifact(runDir, current?.attachments?.find(file => file.name.endsWith('-diff.png'))?.path); + if (current?.status === 'diff' && !images.diff) + errors.push({ + phase: 'current', + stage: 'report', + category: 'missing_artifact', + code: 'MISSING_ARTIFACT', + message: '缺少差异图' + }); + const executionError = + errors.some(error => error.category !== 'visual_difference') || + [baseline, current].some(result => result?.status === 'error'); + const status = executionError + ? 'error' + : baseline?.status !== 'passed' || !current || current.status === 'not_run' + ? 'not_run' + : current.status; + const attachments = []; + for (const [phase, result] of [ + ['baseline', baseline], + ['current', current] + ]) { + for (const file of result?.attachments ?? []) { + const relative = await artifact(runDir, file.path); + if (relative) attachments.push({ phase, ...file, path: relative }); + } + } + cases.push({ + ...item, + status, + ...(status === 'not_run' + ? { + blockedBy: { + phase: baseline?.status === 'passed' ? 'current' : 'baseline', + reason: '前序阶段失败或该阶段未完成' + } + } + : {}), + phases: { baseline: baseline?.status ?? 'not_run', current: current?.status ?? 'not_run' }, + durationMs: (baseline?.durationMs ?? 0) + (current?.durationMs ?? 0), + images, + attachments, + errors, + rerun: `node packages/vtable/scripts/visual-test.mjs ${baselineArg} --case ${quote(item.id)}`.replace(' ', ' ') + }); + } + const counts = Object.fromEntries( + ['passed', 'diff', 'error', 'not_run'].map(status => [status, cases.filter(item => item.status === status).length]) + ); + const complete = + cases.length > 0 && + cases.every(item => ['passed', 'diff'].includes(item.status)) && + !issues.some(issue => ['RESULT_SET_MISMATCH', 'RUN_INCOMPLETE'].includes(issue.code)); + const status = summary.status === 'error' || issues.length || !complete ? 'error' : counts.diff ? 'diff' : 'passed'; + const logs = {}; + for (const name of [ + 'build.log', + 'baseline.log', + 'current.log', + 'baseline-report/index.html', + 'current-report/index.html' + ]) { + const relative = await artifact(runDir, name); + if (relative) logs[name] = relative; + } + const { baselineResult, currentResult, ...metadata } = summary; + const report = { + ...metadata, + schemaVersion: 1, + finalized: summary.finalized ?? true, + status, + complete, + counts, + cases, + issues, + logs, + rerun: selection + ? [ + 'node packages/vtable/scripts/visual-test.mjs', + baselineArg, + selection.mode === 'all' + ? '' + : `${selection.mode === 'directory' ? '--dir' : '--case'} ${quote(selection.value)}` + ] + .filter(Boolean) + .join(' ') + : null, + environment: { + ...summary.environment, + browser: phases.current?.browser ?? phases.baseline?.browser ?? summary.environment.browser + } + }; + // 汇总只生成新对象;HTML 和 Markdown 不改变调用方的状态。 + report.timings = { ...summary.timings, reportMs: Math.round(performance.now() - reportStarted) }; + await writeJson(path.join(runDir, 'summary.json'), report); + await fs.writeFile(path.join(runDir, 'agent-summary.md'), agentSummary(report)); + await fs.writeFile(path.join(runDir, 'index.html'), renderHtml(report)); + report.timings.reportMs = Math.round(performance.now() - reportStarted); + if (report.timings.totalMs !== undefined) report.timings.totalMs += report.timings.reportMs; + await writeJson(path.join(runDir, 'summary.json'), report); + await fs.writeFile(path.join(runDir, 'agent-summary.md'), agentSummary(report)); + await fs.writeFile(path.join(runDir, 'index.html'), renderHtml(report)); + return report; +} + +/** 输出失败优先的纯文本入口,让 Agent 按路径读取证据而非消耗 Base64。 */ +function agentSummary(report) { + const lines = [ + '# VTable 视觉回归结果', + '', + `状态:${report.status};完整比较:${report.complete};${JSON.stringify(report.counts)}`, + `测试范围:${selectionDescription(report)}`, + ...(report.rerun ? [`本次范围复现:\`${report.rerun}\``] : []), + '', + `基线:${report.baseline?.repository ?? '未解析'} @ ${report.baseline?.sha ?? '未解析'}`, + `本地:${report.local?.head ?? '未知'};dirty=${report.local?.dirty ?? '未知'};工作区摘要=${ + report.local?.digest ?? '未知' + }`, + `环境:${JSON.stringify(report.environment)}`, + '', + '完整结构化结果:[summary.json](summary.json)(schemaVersion=1);所有证据路径相对于此报告目录。', + '以下记录测试事实,不判断差异是否属于缺陷。复现命令在仓库根目录执行;基线 SHA 固定,本地工作区需要与摘要一致。', + '用例 source.path 指向仓库文件;source.frozenPath 指向本次运行冻结文件。', + '截图需由支持图片的 Agent 按需打开;本报告不调用模型,也不自动接受差异。', + '' + ]; + for (const issue of report.issues) + lines.push( + `## 运行错误:${issue.phase} / ${issue.code ?? issue.category}`, + '', + ...issue.message.split('\n').map(line => `> ${line}`), + '' + ); + for (const item of report.cases.filter(item => item.status !== 'passed')) { + lines.push( + `## ${item.id} — ${item.status}`, + '', + item.purpose, + '', + `源码:${item.source?.path ?? '未知'}:${item.source?.line ?? '?'};冻结副本:${ + item.source?.frozenPath ?? '未知' + }`, + `阶段:${JSON.stringify(item.phases)}`, + '', + `复现:\`${item.rerun}\``, + '' + ); + for (const [kind, file] of Object.entries(item.images)) + lines.push(`- ${kind}:${file ? `[${file}](${file})` : '未生成或缺失'}`); + for (const error of item.errors) + lines.push( + '', + `${error.phase} / ${error.stage} / ${error.code ?? error.category}`, + ...error.message.split('\n').map(line => `> ${line}`) + ); + lines.push(''); + } + if (report.complete && !report.counts.diff) lines.push('所选用例全部通过,详情见 summary.json。', ''); + lines.push('## 日志与详细报告', '', ...Object.values(report.logs).map(file => `- [${file}](${file})`), ''); + return lines.join('\n'); +} + +/** 生成无需服务和网络的三图报告;原图链接保留完整分辨率。 */ +function renderHtml(report) { + const labels = { passed: '通过', diff: '视觉差异', error: '执行错误', not_run: '未完成' }; + const link = (file, label) => `${escape(label)}`; + const baseline = report.baseline?.repository === 'working-tree' ? '工作区自比较' : '官方基线'; + const cards = report.cases + .map( + item => `
+

${escape(item.id)}

${ + labels[item.status] + }${(item.durationMs / 1000).toFixed(1)}s
+

${escape(item.purpose)}

${[ + ['baseline', `${baseline} · ${report.baseline?.sha?.slice(0, 8) ?? '?'}`], + ['current', `本地 · ${report.local?.head?.slice(0, 8) ?? '?'}${report.local?.dirty ? ' + 未提交修改' : ''}`], + ['diff', 'Diff · 差异图'] + ] + .map( + ([kind, title]) => + `
${escape(title)}
${ + item.images[kind] + ? `${escape(`${item.id} ${title}`)}` + : `
${ + kind === 'diff' && item.status === 'passed' ? '无视觉差异' : '未生成或缺失;请查看状态与诊断' + }
` + }
` + ) + .join('')}
+

用例:${escape(item.source?.path)}:${item.source?.line ?? '?'} · ${ + item.source?.frozenPath ? link(item.source.frozenPath, '查看冻结用例') : '' + } · baseline=${item.phases.baseline} / current=${item.phases.current}

+
${escape(item.rerun)}
+ ${ + item.errors.length + ? `
诊断(${ + item.errors.length + })${item.errors + .map( + error => + `

${escape(`${error.phase} / ${error.stage} / ${error.code ?? error.category}`)}

${escape(
+                  error.message
+                )}
` + ) + .join('')}
` + : '' + } + ${ + item.attachments.length + ? `
证据文件
    ${item.attachments + .map(file => `
  • ${escape(file.phase)} · ${link(file.path, file.name)}
  • `) + .join('')}
` + : '' + }
` + ) + .join(''); + return `VTable 视觉回归 · ${ + labels[report.status] + } +

VTable 视觉回归 · ${labels[report.status]}

${escape(report.startedAt)} · ${ + report.complete ? '所选用例完成比较' : '比较未完整完成' + } · ${escape(report.environment.platform)} / ${escape(report.environment.arch)} · Chromium ${escape( + report.environment.browser ?? '未启动' + )}

基线 ${escape(report.baseline?.sha ?? '未解析')} · 本地 ${escape(report.local?.head ?? '未解析')}${ + report.local?.dirty ? '(有未提交修改)' : '' + }

${escape(selectionDescription(report))}

+
${Object.entries(report.counts) + .map(([status, count]) => `
${count}${labels[status]}
`) + .join('')}
+

同机、同一套用例对比。点击图片打开原图;视觉差异需要审查,不自动判为缺陷。

+ ${ + report.rerun + ? `

本次范围复现:

${escape( + report.rerun + )}
` + : '' + } + ${report.issues + .map( + issue => + `
${escape( + `${issue.phase} / ${issue.code ?? issue.category}` + )}
${escape(issue.message)}
` + ) + .join('')} +
${cards} +
+ `; +} + +/** 在报告目录内原子替换 JSON,避免读者看到半写入的终态。 */ +async function writeJson(file, value) { + const temporary = `${file}.tmp`; + await fs.writeFile(temporary, JSON.stringify(value, null, 2)); + await fs.rename(temporary, file); +} diff --git a/packages/vtable/__tests__/visual/reporter.mjs b/packages/vtable/__tests__/visual/reporter.mjs new file mode 100644 index 0000000000..153dfc10bd --- /dev/null +++ b/packages/vtable/__tests__/visual/reporter.mjs @@ -0,0 +1,90 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { stripVTControlCharacters } from 'node:util'; + +/** 每个完成用例即保存证据,不依赖进程正常结束才落盘。 */ +export default class VisualReporter { + tests = []; + errors = []; + onError(error) { + // 记录 worker 等运行级错误,并保留已完成用例。 + this.errors.push(stripVTControlCharacters(error.message ?? String(error))); + this.persist(false); + } + onTestEnd(test, result) { + // diff 标记仅由截图适配层在确认原生比较证据完整后添加。 + const difference = result.attachments.some(item => item.name === 'visual-difference'); + const stage = test.annotations.find(item => item.type === 'stage')?.description ?? 'setup'; + const category = + result.status === 'timedOut' + ? 'timeout' + : test.annotations.find(item => item.type === 'failure-category')?.description || stage; + const codes = { + resource: 'RESOURCE_FAILED', + render: 'RENDER_FAILED', + interaction: 'INTERACTION_ASSERTION_FAILED', + verify: 'CASE_ASSERTION_FAILED', + screenshot: 'SCREENSHOT_FAILED', + comparison: 'COMPARISON_FAILED', + timeout: 'TIMEOUT', + missing_artifact: 'MISSING_ARTIFACT', + setup: 'PREFLIGHT_FAILED' + }; + this.browser = test.annotations.find(item => item.type === 'browser')?.description ?? this.browser; + this.tests.push({ + id: test.title, + status: + result.status === 'passed' + ? 'passed' + : result.status === 'skipped' + ? 'not_run' + : result.status === 'failed' && difference + ? 'diff' + : 'error', + stage, + category, + code: + result.status === 'passed' + ? null + : result.status === 'skipped' + ? 'RUN_INCOMPLETE' + : difference + ? 'VISUAL_DIFFERENCE' + : codes[category] ?? 'EXECUTION_FAILED', + durationMs: result.duration, + errors: result.errors.map(error => stripVTControlCharacters(error.message ?? String(error))), + attachments: result.attachments + .filter(item => item.path) + .map(item => ({ + name: item.name, + contentType: item.contentType, + path: path.relative(process.env.VISUAL_RUN_DIR, item.path).split(path.sep).join('/') + })) + }); + this.persist(false); + } + persist(finalized, endedStatus) { + // 原子替换阶段结果;未终结的阶段始终不宣称完整成功。 + const status = + !finalized || + this.errors.length || + !this.tests.length || + this.tests.some(test => ['error', 'not_run'].includes(test.status)) || + ['interrupted', 'timedout'].includes(endedStatus) + ? 'error' + : this.tests.some(test => test.status === 'diff') + ? 'diff' + : 'passed'; + const file = path.join(process.env.VISUAL_RUN_DIR, `${process.env.VISUAL_PHASE}-result.json`); + const temporary = `${file}.tmp`; + fs.writeFileSync( + temporary, + JSON.stringify({ finalized, status, browser: this.browser, tests: this.tests, errors: this.errors }, null, 2) + ); + fs.renameSync(temporary, file); + } + onEnd(result) { + // 最终更新状态,保留每个用例独立写入的原始诊断。 + this.persist(true, result.status); + } +} diff --git a/packages/vtable/__tests__/visual/settings.mjs b/packages/vtable/__tests__/visual/settings.mjs new file mode 100644 index 0000000000..57ca4183b4 --- /dev/null +++ b/packages/vtable/__tests__/visual/settings.mjs @@ -0,0 +1,12 @@ +/** 两阶段共用的确定性环境与像素比较策略。 */ +export const settings = { + viewport: { width: 1000, height: 800 }, + table: { width: 800, height: 600 }, + deviceScaleFactor: 1, + locale: 'en-US', + timezoneId: 'UTC', + fontFamily: 'Arial', + timeout: 30000, + stableTimeout: 5000, + comparison: { threshold: 0.1, maxDiffPixels: 0 } +}; diff --git a/packages/vtable/__tests__/visual/visual.spec.mjs b/packages/vtable/__tests__/visual/visual.spec.mjs new file mode 100644 index 0000000000..9517da521e --- /dev/null +++ b/packages/vtable/__tests__/visual/visual.spec.mjs @@ -0,0 +1,135 @@ +import fs from 'node:fs/promises'; +import { test, expect } from '@playwright/test'; +import { cases, loadCase } from './cases/index.mjs'; +import { settings } from './settings.mjs'; + +/** 等待两张连续截图完全一致;持续变化视为执行错误而非像素差异。 */ +async function stableScreenshot(page) { + let previous; + const deadline = Date.now() + settings.stableTimeout; + while (Date.now() < deadline) { + const current = await page.screenshot({ animations: 'disabled', timeout: 5000 }); + if (previous?.equals(current)) return current; + previous = current; + await page.waitForTimeout(100); + } + throw new Error('截图持续变化,5 秒内未稳定'); +} + +/** 检查表格确实生成了可见画布与非空像素,避免两侧同为空白时误通过。 */ +async function checkDrawing(page, errors) { + if (errors.length) throw new Error(errors.join('\n')); + await page.evaluate(() => { + if (window.__visualErrors.length) throw new Error(window.__visualErrors.join('\n')); + const container = document.getElementById('table'); + const canvases = [...container.querySelectorAll('canvas')].filter(canvas => canvas.width > 0 && canvas.height > 0); + if (!canvases.length) throw new Error('没有有效表格画布'); + let ink = 0; + for (const canvas of canvases) { + const context = canvas.getContext('2d'); + if (!context) continue; + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + for (let i = 0; i < pixels.length; i += 4) + if (pixels[i + 3] && (pixels[i] < 245 || pixels[i + 1] < 245 || pixels[i + 2] < 245)) ink++; + } + if (ink < 100) throw new Error('表格没有有效绘制'); + }); +} + +const selectedIds = JSON.parse(process.env.VISUAL_CASE_IDS); +for (const metadata of cases.filter(item => selectedIds.includes(item.id))) { + const item = { ...metadata, ...(await loadCase(metadata)) }; + test(item.id, async ({ page, browser }, info) => { + // 记录来源和浏览器版本,并收集整个用例生命周期内的异常。 + info.annotations.push( + { type: 'purpose', description: item.purpose }, + { type: 'browser', description: browser.version() } + ); + const stage = { type: 'stage', description: 'resource' }; + info.annotations.push(stage); + const failure = { type: 'failure-category', description: '' }; + info.annotations.push(failure); + const errors = []; + const base = process.env.VISUAL_BASE_URL; + page.on('pageerror', error => { + failure.description = 'render'; + errors.push(error.message); + }); + page.on('requestfailed', request => { + failure.description = 'resource'; + errors.push(`资源失败:${request.url()} ${request.failure()?.errorText}`); + }); + page.on('response', response => { + if (response.status() >= 400) { + failure.description = 'resource'; + errors.push(`HTTP ${response.status()}:${response.url()}`); + } + }); + await page.route('**/*', route => { + // 截图阶段禁止 CDN 和内网请求。 + if (new URL(route.request().url()).origin === base) return route.continue(); + failure.description = 'resource'; + errors.push(`禁止外部请求:${route.request().url()}`); + return route.abort(); + }); + try { + await page.goto(`${base}/suite/page.html?phase=${process.env.VISUAL_PHASE}&case=${item.id}`); + stage.description = 'render'; + await page.waitForFunction(() => window.__visualReady || window.__visualErrors?.length); + await checkDrawing(page, errors); + stage.description = 'interaction'; + await item.exercise?.(page); + stage.description = item.exercise ? 'interaction' : 'verify'; + await item.verify(page); + stage.description = 'screenshot'; + await page.evaluate(() => document.fonts.ready); + const screenshot = await stableScreenshot(page); + await checkDrawing(page, errors); + const renderedPath = info.outputPath('rendered.png'); + await fs.writeFile(renderedPath, screenshot); + await info.attach('rendered', { path: renderedPath, contentType: 'image/png' }); + stage.description = 'comparison'; + const name = `${item.id}.png`; + if (process.env.VISUAL_PHASE === 'current') { + // 缺失基线必须在图片断言之前归类为执行错误。 + try { + await fs.access(info.snapshotPath(name)); + } catch (error) { + failure.description = 'missing_artifact'; + throw error; + } + } + try { + expect(screenshot).toMatchSnapshot(name); + } catch (error) { + if (process.env.VISUAL_PHASE === 'current' && (await hasComparisonEvidence(info, name))) + await info.attach('visual-difference', { + body: Buffer.from('图像存在差异,请检查是否符合预期'), + contentType: 'text/plain' + }); + throw error; + } + } finally { + // 显式释放图表,浏览器 context 由 Playwright fixture 自动回收。 + await page.evaluate(() => window.__visualTable?.release?.()).catch(() => {}); + } + }); +} + +/** 只有原生比较生成完整 PNG 证据时才允许把断言失败归类为视觉差异。 */ +async function hasComparisonEvidence(info, name) { + const stem = name.slice(0, -4); + const dimensions = []; + for (const kind of ['expected', 'actual', 'diff']) { + const file = info.attachments.find(item => item.name === `${stem}-${kind}.png`)?.path; + if (!file) return false; + try { + const bytes = await fs.readFile(file); + if (bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a') return false; + dimensions.push(`${bytes.readUInt32BE(16)}x${bytes.readUInt32BE(20)}`); + } catch { + return false; + } + } + return new Set(dimensions).size === 1; +} diff --git a/packages/vtable/package.json b/packages/vtable/package.json index 1d94e3775a..bc109d8f59 100644 --- a/packages/vtable/package.json +++ b/packages/vtable/package.json @@ -47,6 +47,7 @@ "start": "vite serve examples", "test": "jest --silent", "test:firefox": "playwright test --config playwright.config.ts", + "test:visual": "node scripts/visual-test.mjs", "test-cov": "jest --coverage", "ci": "ts-node --transpileOnly --skipProject ./scripts/trigger-test.ts", "build:es": "bundle --clean -f es --ignorePostTasks", diff --git a/packages/vtable/scripts/visual-test.mjs b/packages/vtable/scripts/visual-test.mjs new file mode 100644 index 0000000000..dce81a59d8 --- /dev/null +++ b/packages/vtable/scripts/visual-test.mjs @@ -0,0 +1,86 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { createRuntime, fault } from './visual/runtime.mjs'; +import { loadCases, preflight, runVisual } from './visual/runner.mjs'; + +/** 解析公共命令;帮助不加载 Playwright,非法选项仍明确报错。 */ +export function parseOptions(args) { + let values, tokens; + try { + ({ values, tokens } = parseArgs({ + args, + tokens: true, + options: { + baseline: { type: 'string' }, + case: { type: 'string' }, + dir: { type: 'string' }, + 'self-compare': { type: 'boolean' }, + list: { type: 'boolean' }, + check: { type: 'boolean' }, + help: { type: 'boolean' } + } + })); + } catch (error) { + throw fault('INVALID_ARGUMENT', error.message, error); + } + if (values.help) return values; + if (tokens.filter(token => token.kind === 'option' && token.name === 'dir').length > 1) + throw fault('INVALID_ARGUMENT', '--dir 只能指定一次'); + if (values.dir !== undefined && !/^[a-z][a-z0-9-]*(\/[a-z][a-z0-9-]*)*$/.test(values.dir)) + throw fault('INVALID_ARGUMENT', '--dir 需要 cases 内的相对目录,例如 table/cells'); + if (values.dir !== undefined && values.case !== undefined) throw fault('INVALID_ARGUMENT', '--dir 与 --case 互斥'); + if ( + (values.list || values.check) && + ((values.list && values.check) || + values.baseline !== undefined || + values.case !== undefined || + values.dir !== undefined || + values['self-compare']) + ) + throw fault('INVALID_ARGUMENT', '--list/--check 必须独立使用'); + if (values.baseline !== undefined && !/^[a-f0-9]{40}$/i.test(values.baseline)) + throw fault('INVALID_ARGUMENT', '--baseline 需要完整 40 位 SHA'); + if (values.baseline && values['self-compare']) throw fault('INVALID_ARGUMENT', '--baseline 与 --self-compare 互斥'); + if (values.case !== undefined && !/^[a-z][a-z0-9-]*$/.test(values.case)) + throw fault('INVALID_ARGUMENT', '--case 需要有效用例 ID'); + return values; +} +/** 入口只管理参数、预检模式和运行生命周期。 */ +async function main() { + const options = parseOptions(process.argv.slice(2)); + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); + if (options.help) { + console.log( + 'node packages/vtable/scripts/visual-test.mjs [--baseline <40位SHA> | --self-compare] [--dir <目录> | --case ]\n默认运行全部本地用例;--dir 递归运行 cases 内的一个目录(例如 table/cells)。\n--list 列举用例;--check 本机环境检查;--help 帮助\n默认:官方 VisActor/VTable develop。退出码:0 通过,1 视觉差异,2 执行错误。' + ); + return; + } + if (options.list || options.check) { + const cases = await loadCases(path.join(root, 'packages/vtable/__tests__/visual')); + if (options.list) { + console.log( + cases.map(item => `${item.id}\t${item.purpose}\t${item.file}\t${item.sourceExample ?? ''}`).join('\n') + ); + return; + } + await fs.mkdir(path.join(root, '.vtable-visual'), { recursive: true }); + const runtime = createRuntime(); + const detach = runtime.listenSignals(); + try { + console.log(JSON.stringify({ status: 'passed', ...(await preflight(root, runtime)), cases: cases.length })); + } finally { + const errors = await runtime.cleanup(); + detach(); + if (errors.length) throw fault('CLEANUP_FAILED', JSON.stringify(errors)); + } + return; + } + process.exitCode = await runVisual(root, options); +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) + main().catch(error => { + console.error(`${error.code ?? 'EXECUTION_FAILED'}: ${error.message}`); + process.exitCode = 2; + }); diff --git a/packages/vtable/scripts/visual-test.test.mjs b/packages/vtable/scripts/visual-test.test.mjs new file mode 100644 index 0000000000..5d81e29be4 --- /dev/null +++ b/packages/vtable/scripts/visual-test.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseOptions } from './visual-test.mjs'; +import { loadCases, selectCases } from './visual/runner.mjs'; + +/** 用最小契约检查拦住无效范围、重复 ID 和缺失来源模块。 */ +test('视觉用例清单与范围选择', async () => { + const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + const cases = await loadCases(path.join(root, '__tests__/visual')); + assert.ok(cases.length >= 2); + assert.equal(new Set(cases.map(item => item.id)).size, cases.length); + assert.ok(selectCases(cases, { dir: 'pivot' }).every(item => item.file.startsWith('./pivot/'))); + assert.deepEqual(selectCases(cases, { case: 'list-basic' }).map(item => item.id), ['list-basic']); + assert.throws(() => parseOptions(['--dir', '../pivot']), /--dir/); + assert.throws(() => parseOptions(['--dir', 'pivot', '--case', 'list-basic']), /互斥/); + assert.throws(() => selectCases(cases, { dir: 'missing' }), /没有匹配用例/); +}); diff --git a/packages/vtable/scripts/visual/build.mjs b/packages/vtable/scripts/visual/build.mjs new file mode 100644 index 0000000000..9919c7c2fb --- /dev/null +++ b/packages/vtable/scripts/visual/build.mjs @@ -0,0 +1,137 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { digest, atomicJson, fault } from './runtime.mjs'; + +export const official = 'https://github.com/VisActor/VTable.git'; +export const bundleFiles = { + vtable: ['packages/vtable', 'dist/vtable.js'], + editors: ['packages/vtable-editors', 'dist/vtable-editors.js'], + gantt: ['packages/vtable-gantt', 'dist/vtable-gantt.js'], + plugins: ['packages/vtable-plugins', 'dist/vtable-plugins.js'], + sheet: ['packages/vtable-sheet', 'dist/vtable-sheet.js'] +}; +const buildEnv = { NODE_ENV: 'production', BUNDLE_ANALYZE: '', NODE_OPTIONS: '--max-old-space-size=10240' }; + +/** 查询指定仓库,不修改分支、索引或远端配置。 */ +export function git(root, args) { + return execFileSync('git', args, { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim(); +} +/** 记录提交、已跟踪差异和未跟踪文件,构建期变更会使运行失败。 */ +export async function workingTree(root) { + const status = git(root, ['status', '--short']); + const patch = execFileSync('git', ['diff', 'HEAD', '--binary', '--full-index'], { cwd: root, maxBuffer: 64 * 1024 * 1024 }); + const files = execFileSync('git', ['ls-files', '--others', '--exclude-standard', '-z'], { cwd: root, encoding: 'utf8' }) + .split('\0').filter(Boolean).sort(); + const untracked = []; + for (const file of files) { + const full = path.join(root, file); + const stat = await fs.lstat(full); + untracked.push([file, digest(stat.isSymbolicLink() ? await fs.readlink(full) : await fs.readFile(full))]); + } + return { head: git(root, ['rev-parse', 'HEAD']), dirty: Boolean(status), status, digest: digest(Buffer.concat([patch, Buffer.from(JSON.stringify(untracked))])) }; +} +/** 每次从官方仓库解析本轮 develop 或指定提交,不使用 fork 的 origin。 */ +export async function resolveBaseline(root, sha, runtime, log) { + await runtime.run('git', ['fetch', '--no-tags', official, sha || 'refs/heads/develop'], root, log, { code: 'BASELINE_FETCH_FAILED' }); + const resolved = git(root, ['rev-parse', 'FETCH_HEAD^{commit}']); + if (sha && resolved !== sha.toLowerCase()) throw fault('BASELINE_FETCH_FAILED', '基线 SHA 与请求不一致'); + return resolved; +} +/** 构建所选包及其工作区依赖,并仅复制本轮产物到隔离运行目录。 */ +export async function build(root, runDir, phase, names, runtime, log) { + const rushx = path.join(root, 'common/scripts/install-run-rushx.js'); + // Gantt 的 bundler.config 从 bundler 工具包解析 Rollup 插件。 + const env = { ...buildEnv, NODE_PATH: path.join(root, 'tools/bundler/node_modules') }; + await runtime.run(process.execPath, [rushx, 'build'], path.join(root, 'tools/bundler'), log, { code: 'BUILD_FAILED', env }); + const bundle = path.join(root, 'tools/bundler/bin/index.js'); + const need = new Set(names); + const esPackages = ['vtable-editors', 'vtable']; + if (['gantt', 'plugins', 'sheet'].some(name => need.has(name))) esPackages.push('vtable-gantt'); + if (['plugins', 'sheet'].some(name => need.has(name))) esPackages.push('vtable-plugins'); + for (const name of esPackages) + await runtime.run(process.execPath, [bundle, '-f', 'es', '--ignorePostTasks'], path.join(root, 'packages', name), log, { code: 'BUILD_FAILED', env }); + for (const name of names) { + const destination = path.join(runDir, `${phase}-${name}.js`); + if (name === 'vchart') { + const require = (await import('node:module')).createRequire(path.join(root, 'packages/vtable/package.json')); + const packageRoot = path.dirname(require.resolve('@visactor/vchart/package.json')); + const source = path.join(packageRoot, 'build/index.min.js'); + if (!(await fs.stat(source)).size) throw fault('BUILD_FAILED', '缺少 VChart 本地产物'); + await fs.copyFile(source, destination); + continue; + } + const [folder, relative] = bundleFiles[name] ?? []; + if (!folder) throw fault('BUILD_FAILED', `未知构建包:${name}`); + const output = path.join(folder, relative); + if (git(root, ['ls-files', '--', output])) throw fault('BUILD_FAILED', `构建目标被 Git 跟踪:${output}`); + try { git(root, ['check-ignore', '--no-index', output]); } + catch { throw fault('BUILD_FAILED', `构建目标未忽略:${output}`); } + await fs.rm(path.join(root, output), { force: true }); + await runtime.run(process.execPath, [bundle, '-f', 'umd', '--minify=false', '--ignorePostTasks'], path.join(root, folder), log, { code: 'BUILD_FAILED', env }); + if (!(await fs.stat(path.join(root, output))).size) throw fault('BUILD_FAILED', `没有生成有效产物:${output}`); + await fs.copyFile(path.join(root, output), destination); + } +} +/** 逐文件校验缓存摘要;坏缓存只导致重新构建。 */ +async function readCache(cache, key, names) { + try { + const metadata = JSON.parse(await fs.readFile(path.join(cache, 'metadata.json'), 'utf8')); + if (metadata.key !== key) return null; + const files = new Map(); + for (const name of names) { + const bytes = await fs.readFile(path.join(cache, `${name}.js`)); + if (!bytes.length || digest(bytes) !== metadata.files?.[name]) return null; + files.set(name, bytes); + } + return files; + } catch (error) { + if (error.code === 'ENOENT' || error instanceof SyntaxError) return null; + throw error; + } +} +/** 成功构建后原子发布完整缓存,不用半份产物覆盖既有缓存。 */ +async function publishCache(cache, key, files) { + const temporary = await fs.mkdtemp(`${cache}-next-`); + const old = `${cache}-previous`; + try { + const hashes = {}; + for (const [name, bytes] of files) { + await fs.writeFile(path.join(temporary, `${name}.js`), bytes); + hashes[name] = digest(bytes); + } + await atomicJson(path.join(temporary, 'metadata.json'), { key, files: hashes }); + await fs.rm(old, { recursive: true, force: true }); + try { await fs.rename(cache, old); } catch (error) { if (error.code !== 'ENOENT') throw error; } + try { await fs.rename(temporary, cache); } + catch (error) { try { await fs.rename(old, cache); } catch {} throw error; } + await fs.rm(old, { recursive: true, force: true }); + } finally { await fs.rm(temporary, { recursive: true, force: true }); } +} +/** 按 SHA、锁文件、环境、包集合和构建配方缓存最近一次官方产物。 */ +export async function baselineBuild(root, runDir, sha, names, runtime, report, timed) { + const log = path.join(runDir, 'build.log'); + const key = digest(JSON.stringify({ + sha, names, lock: git(root, ['show', `${sha}:common/config/rush/pnpm-lock.yaml`]), + node: process.version, platform: process.platform, arch: process.arch, + recipe: digest(await fs.readFile(fileURLToPath(import.meta.url))), buildEnv, + tools: git(root, ['show', `${sha}:rush.json`]) + })); + const cache = path.join(root, '.vtable-visual/baseline-cache'); + const cached = await readCache(cache, key, names); + if (cached) { + for (const [name, bytes] of cached) await fs.writeFile(path.join(runDir, `baseline-${name}.js`), bytes); + report.baseline.cacheHit = true; + return; + } + const worktree = path.join(runDir, 'worktree'); + runtime.defer(async () => { + if (await fs.stat(worktree).catch(error => error.code === 'ENOENT' ? null : Promise.reject(error))) + execFileSync('git', ['worktree', 'remove', '--force', worktree], { cwd: root, stdio: 'pipe', timeout: 60000 }); + }); + await runtime.run('git', ['worktree', 'add', '--detach', worktree, sha], root, log, { code: 'BUILD_FAILED' }); + await timed('baselineInstallMs', () => runtime.run(process.execPath, ['common/scripts/install-run-rush.js', 'install', '--ignore-hooks'], worktree, log, { code: 'BUILD_FAILED', env: { NODE_ENV: 'development' } })); + await timed('baselineCompileMs', () => build(worktree, runDir, 'baseline', names, runtime, log)); + await publishCache(cache, key, new Map(await Promise.all(names.map(async name => [name, await fs.readFile(path.join(runDir, `baseline-${name}.js`))])))); +} diff --git a/packages/vtable/scripts/visual/runner.mjs b/packages/vtable/scripts/visual/runner.mjs new file mode 100644 index 0000000000..41b3da65bb --- /dev/null +++ b/packages/vtable/scripts/visual/runner.mjs @@ -0,0 +1,350 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import { randomUUID } from 'node:crypto'; +import { createRuntime, serve, fault, digest, atomicJson, fileManifest } from './runtime.mjs'; +import { git, workingTree, resolveBaseline, build, baselineBuild, official } from './build.mjs'; +import { saveReport } from '../../__tests__/visual/report.mjs'; + +/** 验证元数据和可执行契约,只导入实际使用的清单目录。 */ +export async function loadCases(suite, caseId) { + try { + // 列举也检查全部目录,导入前拒绝父目录和辅助资源中的符号链接。 + if ((await fs.lstat(path.join(suite, 'cases'))).isSymbolicLink()) throw new Error('cases 不能为符号链接'); + await fileManifest(path.join(suite, 'cases')); + const { cases, loadCase } = await import(pathToFileURL(path.join(suite, 'cases/index.mjs'))); + if (!Array.isArray(cases) || !cases.length) throw new Error('用例清单为空'); + const ids = new Set(); + for (const item of cases) { + if (!/^[a-z][a-z0-9-]*$/.test(item.id) || ids.has(item.id)) throw new Error(`非法或重复 ID:${item.id}`); + ids.add(item.id); + if ( + !item.purpose || + (item.sourceExample !== undefined && (typeof item.sourceExample !== 'string' || !item.sourceExample.trim())) || + !Array.isArray(item.bundles) || !item.bundles.length || item.bundles.some(name => !['vtable', 'editors', 'gantt', 'plugins', 'sheet', 'vchart'].includes(name)) || new Set(item.bundles).size !== item.bundles.length || + typeof item.file !== 'string' || + !/^\.\/([a-z][a-z0-9-]*\/)*[a-z][a-z0-9-]*\.mjs$/.test(item.file) + ) + throw new Error(`非法用例元数据:${item.id}`); + const file = path.join(suite, 'cases', item.file); + if ((await fs.lstat(file)).isSymbolicLink()) throw new Error(`用例不能为符号链接:${item.id}`); + const module = await loadCase(item); + if ( + typeof module?.mount !== 'function' || + typeof module.verify !== 'function' || + (module.exercise !== undefined && typeof module.exercise !== 'function') + ) + throw new Error(`非法用例导出:${item.id}`); + } + return selectCases(cases, { case: caseId }); + } catch (error) { + throw fault('CASE_MANIFEST_INVALID', error.message, error); + } +} +/** 从已验证的冻结清单选择准确范围;目录按层级匹配,不自动扩大或发现文件。 */ +export function selectCases(cases, options = {}) { + if (options.case !== undefined && options.dir !== undefined) throw fault('INVALID_ARGUMENT', '--dir 与 --case 互斥'); + if (options.dir !== undefined && !/^[a-z][a-z0-9-]*(\/[a-z][a-z0-9-]*)*$/.test(options.dir)) + throw fault('INVALID_ARGUMENT', '非法用例目录'); + const selected = cases.filter(item => + options.case !== undefined + ? item.id === options.case + : options.dir !== undefined + ? item.file.startsWith(`./${options.dir}/`) + : true + ); + if (!selected.length) throw fault('CASE_MANIFEST_INVALID', `没有匹配用例:${options.dir ?? options.case ?? '全部'}`); + return selected; +} +/** 延迟加载 Playwright,确保帮助和列举不依赖浏览器安装。 */ +export function playwright(root) { + try { + return createRequire(path.join(root, 'packages/vtable/package.json'))('@playwright/test'); + } catch (error) { + throw fault('PREFLIGHT_FAILED', '缺少 Playwright,请先执行 Rush install', error); + } +} +/** 检查本机依赖及浏览器实际启动能力,不下载、不修改依赖。 */ +export async function preflight(root, runtime) { + if (Number(process.versions.node.split('.')[0]) !== 22 || !['darwin', 'linux'].includes(process.platform)) + throw fault('PREFLIGHT_FAILED', '需要 Node.js 22 和 macOS/Linux'); + try { + git(root, ['--version']); + for (const folder of ['tools/bundler', 'packages/vtable']) { + const require = createRequire(path.join(root, folder, 'package.json')); + for (const dependency of folder === 'tools/bundler' + ? ['typescript'] + : ['@internal/bundler/package.json', '@visactor/vutils']) + require.resolve(dependency); + } + playwright(root); + await runtime.run( + process.execPath, + [ + '-e', + 'const {chromium}=require(process.argv[1]);(async()=>{const browser=await chromium.launch();await browser.close();})().catch(e=>{console.error(e);process.exitCode=2;});', + createRequire(path.join(root, 'packages/vtable/package.json')).resolve('@playwright/test') + ], + root, + path.join(root, '.vtable-visual/preflight.log'), + { code: 'PREFLIGHT_FAILED', timeoutMs: 30000 } + ); + return { + playwright: createRequire(path.join(root, 'packages/vtable/package.json'))('@playwright/test/package.json') + .version + }; + } catch (error) { + throw fault( + runtime.interrupted ? 'RUN_INTERRUPTED' : 'PREFLIGHT_FAILED', + `环境未就绪:${error.message}。依赖使用 Rush install;浏览器使用 Playwright install chromium。`, + error + ); + } +} +/** 精确校验结果集合,而非仅以数量相等视为完整。 */ +export function validateResults(result, ids) { + const found = result.tests?.map(item => item.id) ?? []; + if (found.length !== ids.length || new Set(found).size !== found.length || found.some(id => !ids.includes(id))) + throw fault('RESULT_SET_MISMATCH', '阶段结果与选中用例集合不一致'); +} +/** 运行冻结套件的一个阶段,保留已落盘的原始结果供失败汇总。 */ +export async function executePhase(root, runDir, phase, url, ids, runtime) { + const require = createRequire(path.join(root, 'packages/vtable/package.json')); + await fs.rm(path.join(runDir, `${phase}-result.json`), { force: true }); + const code = await runtime.run( + process.execPath, + [require.resolve('@playwright/test/cli'), 'test', '--config', path.join(runDir, 'suite/playwright.config.mjs')], + root, + path.join(runDir, `${phase}.log`), + { + allowFailure: true, + code: 'EXECUTION_FAILED', + env: { VISUAL_RUN_DIR: runDir, VISUAL_BASE_URL: url, VISUAL_PHASE: phase, VISUAL_CASE_IDS: JSON.stringify(ids) } + } + ); + let result; + try { + result = JSON.parse(await fs.readFile(path.join(runDir, `${phase}-result.json`), 'utf8')); + } catch (error) { + throw fault('RESULT_SET_MISMATCH', `${phase} 缺少有效阶段结果`, error); + } + validateResults(result, ids); + if (!result.finalized || result.status === 'error' || (code !== 0 && !(code === 1 && result.status === 'diff'))) + throw fault('EXECUTION_FAILED', `${phase} 阶段执行失败,请查看 ${phase}-result.json 和 ${phase}.log`); + return result; +} +/** 持有锁的运行才能释放锁,避免误删另一次运行。 */ +export async function acquireLock(output, owner) { + const lock = path.join(output, 'running.lock'); + try { + await fs.mkdir(lock); + } catch (error) { + if (error.code === 'EEXIST') throw fault('RUN_LOCKED', `存在运行锁:${lock};检查 owner.json 后再处理陈旧锁`); + throw error; + } + try { + await atomicJson(path.join(lock, 'owner.json'), owner); + } catch (error) { + await fs.rm(lock, { recursive: true, force: true }); + throw error; + } + return async () => { + // 只有身份相同才能清理,陈旧或未知锁不自动接管。 + const current = JSON.parse(await fs.readFile(path.join(lock, 'owner.json'), 'utf8')); + if (current.runId !== owner.runId) throw fault('CLEANUP_FAILED', '运行锁所有权已改变'); + await fs.rm(lock, { recursive: true, force: true }); + }; +} +/** 编排一次完整对比;所有准备失败和清理失败尽可能进入统一报告。 */ +export async function runVisual(root, options) { + const runtime = createRuntime(); + const detach = runtime.listenSignals(); + const output = path.join(root, '.vtable-visual'); + const runId = `${Date.now()}-${randomUUID().slice(0, 8)}`; + const runDir = path.join(output, 'runs', runId); + const report = { + runId, + finalized: false, + status: 'error', + startedAt: new Date().toISOString(), + request: options, + environment: { node: process.version, platform: process.platform, arch: process.arch }, + timings: {}, + cases: [], + issues: [], + stage: 'preparation' + }; + const started = performance.now(); + let releaseLock; + async function timed(name, action) { + // 失败阶段也记录耗时,便于诊断冷启动和中断。 + const start = performance.now(); + try { + return await action(); + } finally { + report.timings[name] = Math.round(performance.now() - start); + } + } + try { + await fs.mkdir(runDir, { recursive: true }); + console.log(`运行目录:${runDir}`); + await atomicJson(path.join(runDir, 'summary.json'), { + ...report, + schemaVersion: 1, + complete: false, + issues: [{ code: 'RUN_INCOMPLETE', message: '运行尚未完成' }] + }); + releaseLock = await acquireLock(output, { runId, pid: process.pid, startedAt: report.startedAt, runDir }); + const suite = path.join(runDir, 'suite'); + const source = path.join(root, 'packages/vtable/__tests__/visual'); + await fileManifest(source); + await fs.cp(source, suite, { recursive: true }); + report.frozenFiles = await fileManifest(suite); + report.suiteDigest = digest(JSON.stringify(report.frozenFiles)); + const { settings } = await import(pathToFileURL(path.join(suite, 'settings.mjs'))); + report.comparison = settings.comparison; + report.environment = { + ...report.environment, + viewport: settings.viewport, + deviceScaleFactor: settings.deviceScaleFactor, + locale: settings.locale, + timezoneId: settings.timezoneId, + fontFamily: settings.fontFamily + }; + const allCases = await loadCases(suite); + const selected = selectCases(allCases, options); + report.selection = { + mode: options.dir !== undefined ? 'directory' : options.case !== undefined ? 'case' : 'all', + value: options.dir ?? options.case ?? null, + selectedIds: selected.map(item => item.id), + selectedCount: selected.length, + totalCount: allCases.length + }; + console.log(`测试范围:${report.selection.value ?? '全部本地用例'};选中 ${selected.length} / ${allCases.length}`); + const names = ['vtable', 'editors', 'gantt', 'plugins', 'sheet', 'vchart'].filter(name => name === 'vtable' || selected.some(item => item.bundles.includes(name))); + if (!names.includes('vtable')) names.unshift('vtable'); + report.cases = selected.map(({ id, purpose, file, sourceExample, bundles }) => ({ + id, + purpose, + sourceExample, + bundles, + source: { + path: `packages/vtable/__tests__/visual/cases/${file.slice(2)}`, + line: 1, + frozenPath: `suite/cases/${file.slice(2)}` + } + })); + report.environment = { ...report.environment, ...(await timed('preflightMs', () => preflight(root, runtime))) }; + await fs.symlink(path.join(root, 'packages/vtable/node_modules'), path.join(runDir, 'node_modules'), 'dir'); + runtime.defer(() => fs.rm(path.join(runDir, 'node_modules'), { force: true })); + report.local = await workingTree(root); + report.stage = 'fetch'; + const log = path.join(runDir, 'build.log'); + const sha = options['self-compare'] + ? report.local.head + : await timed('fetchMs', () => resolveBaseline(root, options.baseline, runtime, log)); + report.baseline = { + repository: options['self-compare'] ? 'working-tree' : official, + requestedRef: options.baseline ?? (options['self-compare'] ? 'working-tree' : 'refs/heads/develop'), + sha, + cacheHit: false + }; + report.stage = 'localBuild'; + console.log('构建当前工作区…'); + await timed('localBuildMs', () => build(root, runDir, 'current', names, runtime, log)); + const after = await workingTree(root); + if (after.head !== report.local.head || after.digest !== report.local.digest) + throw fault('WORKSPACE_CHANGED', '构建期间工作区或 HEAD 发生变化,请重跑'); + report.stage = 'baselineBuild'; + if (options['self-compare']) + for (const name of names) await fs.copyFile(path.join(runDir, `current-${name}.js`), path.join(runDir, `baseline-${name}.js`)); + else await timed('baselineBuildMs', () => baselineBuild(root, runDir, sha, names, runtime, report, timed)); + report.bundles = Object.fromEntries(await Promise.all(names.map(async name => [name, { + baseline: digest(await fs.readFile(path.join(runDir, `baseline-${name}.js`))), + current: digest(await fs.readFile(path.join(runDir, `current-${name}.js`))) + }]))); + const { server, url } = await serve(runDir); + runtime.defer(async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(resolve)); + }); + const ids = selected.map(item => item.id); + for (const phase of ['baseline', 'current']) { + report.stage = phase; + console.log(`${phase} 截图…`); + report[`${phase}Result`] = await timed(`${phase}ScreenshotsMs`, () => + executePhase(root, runDir, phase, url, ids, runtime) + ); + } + report.status = report.currentResult.status; + } catch (error) { + report.status = 'error'; + const errorCode = error.visualError + ? error.code + : report.stage.endsWith('Build') + ? 'BUILD_FAILED' + : 'EXECUTION_FAILED'; + report.issues.push({ + phase: report.stage, + stage: report.stage, + category: 'execution', + code: errorCode, + message: error.stack ?? String(error) + }); + console.error(`${errorCode}: ${error.message}`); + } finally { + const cleanup = await timed('cleanupMs', () => runtime.cleanup()); + report.issues.push(...cleanup.map(issue => ({ ...issue, phase: 'cleanup', category: 'execution' }))); + if (runtime.interrupted) + report.issues.push({ + phase: report.stage, + category: 'execution', + code: 'RUN_INTERRUPTED', + message: '运行已中断' + }); + if (releaseLock) { + try { + await releaseLock(); + } catch (error) { + report.issues.push({ phase: 'cleanup', category: 'execution', code: 'CLEANUP_FAILED', message: String(error) }); + } + } + detach(); + report.finalized = true; + report.timings.totalMs = Math.round(performance.now() - started); + } + try { + const final = await saveReport(runDir, report); + console.log(`结果:${final.status}\n报告:${path.join(runDir, 'index.html')}`); + return final.status === 'passed' ? 0 : final.status === 'diff' ? 1 : 2; + } catch (error) { + console.error(`REPORT_FAILED: ${error.message};原始证据:${runDir}`); + try { + let evidence = report; + try { + evidence = JSON.parse(await fs.readFile(path.join(runDir, 'summary.json'), 'utf8')); + } catch {} + await atomicJson(path.join(runDir, 'summary.json'), { + ...evidence, + finalized: true, + schemaVersion: 1, + status: 'error', + issues: [ + ...(evidence.issues ?? []), + { phase: 'report', category: 'execution', code: 'REPORT_FAILED', message: String(error) } + ] + }); + } catch {} + try { + await fs.rm(path.join(runDir, 'index.html'), { force: true }); + } catch {} + try { + await fs.writeFile( + path.join(runDir, 'agent-summary.md'), + '# 报告生成失败\n\n状态:error;code:REPORT_FAILED。请读取 summary.json 和阶段日志。\n' + ); + } catch {} + return 2; + } +} diff --git a/packages/vtable/scripts/visual/runtime.mjs b/packages/vtable/scripts/visual/runtime.mjs new file mode 100644 index 0000000000..600ef78e67 --- /dev/null +++ b/packages/vtable/scripts/visual/runtime.mjs @@ -0,0 +1,173 @@ +import { spawn } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; +import { createServer } from 'node:http'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +/** 携带稳定错误码;错误消息保留具体失败证据。 */ +export function fault(code, message, cause) { + return Object.assign(new Error(message, cause ? { cause } : undefined), { code, visualError: true }); +} +/** 计算输入、产物和缓存的 SHA-256 摘要。 */ +export function digest(value) { + return createHash('sha256').update(value).digest('hex'); +} +/** 同目录写临时文件后替换,避免读到半份结果。 */ +export async function atomicJson(file, value) { + const temporary = `${file}.${randomUUID()}.tmp`; + try { + await fs.writeFile(temporary, JSON.stringify(value, null, 2)); + await fs.rename(temporary, file); + } finally { + await fs.rm(temporary, { force: true }); + } +} +/** 顺序尝试所有清理动作,单项失败不阻断后续回收。 */ +export async function cleanupAll(actions) { + const errors = []; + for (const action of actions.reverse()) { + try { + await action(); + } catch (error) { + errors.push({ code: 'CLEANUP_FAILED', message: String(error) }); + } + } + return errors; +} + +/** 管理单次运行的进程组;只有入口显式调用 listenSignals 才注册处理器。 */ +export function createRuntime() { + const children = new Set(); + const cleanups = []; + let interrupted = false; + let killTimer; + function stop(signal) { + // 仅向本次创建的进程组发送信号。 + for (const child of children) { + if (!child.pid) continue; + try { + process.kill(-child.pid, signal); + } catch (error) { + if (error.code !== 'ESRCH') throw error; + } + } + } + function interrupt() { + // 首先通知退出,三秒后强制终止未退出的子进程。 + interrupted = true; + stop('SIGTERM'); + killTimer ??= setTimeout(() => stop('SIGKILL'), 3000); + killTimer.unref(); + } + return { + get interrupted() { + return interrupted; + }, + defer(action) { + cleanups.push(action); + }, + listenSignals() { + // 返回移除函数,导入和完成运行后均不留下信号监听器。 + process.on('SIGINT', interrupt); + process.on('SIGTERM', interrupt); + return () => { + process.off('SIGINT', interrupt); + process.off('SIGTERM', interrupt); + clearTimeout(killTimer); + }; + }, + async run(command, args, cwd, log, options = {}) { + // 使用参数数组启动命令,保存日志;逾时或中断均为执行错误。 + if (interrupted) throw fault('RUN_INTERRUPTED', '测试已中断'); + const handle = await fs.open(log, 'a'); + let child, timer; + try { + await handle.write(`\n$ ${command} ${args.join(' ')}\n`); + child = spawn(command, args, { + cwd, + env: { + ...process.env, + PATH: `${path.dirname(process.execPath)}:${process.env.PATH}`, + CI: '1', + ...options.env + }, + detached: true, + stdio: ['ignore', handle.fd, handle.fd] + }); + children.add(child); + const code = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve(signal ? 2 : code)); + timer = setTimeout(() => reject(fault('TIMEOUT', `命令超时:${log}`)), options.timeoutMs ?? 1200000); + }); + if (interrupted) throw fault('RUN_INTERRUPTED', `运行已中断:${log}`); + if (code !== 0 && !options.allowFailure) + throw fault(options.code ?? 'EXECUTION_FAILED', `命令执行失败 (${code}),请查看 ${log}`); + return code; + } catch (error) { + if (error.visualError) throw error; + throw fault(options.code ?? 'EXECUTION_FAILED', `${error.message};日志:${log}`, error); + } finally { + clearTimeout(timer); + if (child?.pid) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch {} + } + children.delete(child); + await handle.close(); + } + }, + async cleanup() { + // 即使某个清理函数失败,也继续回收剩余资源。 + const errors = []; + try { + stop('SIGKILL'); + } catch (error) { + errors.push({ code: 'CLEANUP_FAILED', message: String(error) }); + } + errors.push(...(await cleanupAll(cleanups))); + cleanups.length = 0; + return errors; + } + }; +} + +/** 仅暴露冻结套件与两侧构建,使用回环地址及系统分配端口。 */ +export async function serve(runDir) { + const server = createServer(async (request, response) => { + try { + const relative = decodeURIComponent(new URL(request.url, 'http://localhost').pathname).slice(1); + if (!/^(suite\/|(?:baseline|current)-[a-z-]+\.js$)/.test(relative) || relative.split(/[\\/]/).includes('..')) + return response.writeHead(404).end(); + const resolved = await fs.realpath(path.join(runDir, relative)); + if (!resolved.startsWith(`${await fs.realpath(runDir)}${path.sep}`)) return response.writeHead(404).end(); + const content = await fs.readFile(resolved); + response.writeHead(200, { + 'Content-Type': relative.endsWith('.html') ? 'text/html' : 'text/javascript', + 'Cache-Control': 'no-store' + }); + response.end(content); + } catch { + response.writeHead(404).end(); + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + return { server, url: `http://127.0.0.1:${server.address().port}` }; +} + +/** 按相对路径递归冻结代码资源,不跟随符号链接到工作区外。 */ +export async function fileManifest(directory, relative = '') { + const entries = []; + for (const name of (await fs.readdir(path.join(directory, relative))).sort()) { + const file = path.posix.join(relative, name); + const stat = await fs.lstat(path.join(directory, file)); + if (stat.isSymbolicLink()) throw fault('CASE_MANIFEST_INVALID', `冻结输入不能包含符号链接:${file}`); + if (stat.isDirectory()) entries.push(...(await fileManifest(directory, file))); + else if (stat.isFile()) entries.push({ path: file, digest: digest(await fs.readFile(path.join(directory, file))) }); + } + return entries; +}