演练: 操作Qt应用中的Tree
背景
需要对标准的树状结构——文件系统进行操作,假设需要访问某个固定的路径,使用Qt提供示例应用——DirView为作为自动化的目标应用。
目标
针对Qt应用中的树控件,也就是TreeView
控件进行自动化。Qt实现树控件的方式更像是一个多层次的表格控件。自动化的目标是能够在DirView应用中,自动的索引到目标路径下。
为了实现该目标,我们需要掌握对树控件的几个操作:
- 选中树节点
- 树节点的展开/折叠
- 搜索树中满足条件的节点(类比列表控件的操作)
在学习这几个操作前,我们先对Qt实现树结构的方法进行一些了解。
Qt中的树控件
下面列举Qt树控件TreeView
的几个特点:
可能延迟加载子节点:对于Qt中有些Tree,例如这里的DirView,会在某个目录节点首次展开时才加载目录里的文件作为子节点。从未被展开起来的子节点可能无法被获取到。因此操作节点最可靠的方式,是按照根节点展开到目标节点路径,来操作目标节点。
Tree结构类似表格:在所有的树控件中,除了各个节点自身外,每个节点都有一些其它的属性,因此对于树控件来说,它不止有多行,而且有多列,类似表格控件。以上述的demo——文件路径视图
dirview
来说,每行都是一个节点,每个节点都是一个文件/文件夹;而每列都有一个属性名,名称、大小、类型、最后修改时间等属性,这是节点的属性。如下:
实际操作
我们先根据目标一步一步掌握基本操作,再通过整合这些操作实现树控件的自动化目标。
项目结构参考
在完成全部的编写后,项目的结构应该呈现如下:
├── features
│ ├── feature1.feature
│ └── step_definitions
│ ├── definitions1.js
│ └── model.tmodel
└── package.json
剧本和模型文件
编辑剧本文件
# language: zh-CN
功能: QtTree自动化_Linux
针对Linux Qt中的TreeView控件进行自动化
场景: 根据itemPath展开树树节点
假如目标树节点的itemPath为"[0, 0]",获取该树节点的对象
那么将目标树节点展开到可视范围内
那么选中目标树节点并验证
那么应用截图
场景: 根据文件路径展开并选中目标树节点
假如展开到"./step_definitions/definitions1.js"文件所在树节点
那么选中目标树节点并验证
那么应用截图
在工具栏中切换文本模式来编辑剧本内容,是一种高级的用法。
剧本编写完毕后,可以点击步骤右侧的灰色箭头,在打开的definitions1.js
脚本文件中生成步骤模板。
编辑模型文件
首先创建模型文件,接着通过模型管理器打开演练所用的应用——DirView
。按照之后的操作,我们只需要识别几个目标控件,一个是树状视图自身的Tree
控件,另外再识别一个任意树节点TreeItem
控件即可。
编写脚本
场景: 根据itemPath展开树树节点
步骤:目标树节点的itemPath为{string},获取该树节点的对象
由于从步骤描述中接收的itemPath
参数为字符串,而我们需要的一个Int[]
也就是整型数组类型,所以使用JSON.parse()
将目标字符串解析为数组,并用于getItem()
方法。
Given("目标树节点的itemPath为{string},获取该树节点的对象", async function (itemPathString) {
let itemPath = JSON.parse(itemPathString);
let targetItem = model.getTree("Dir_View").getItem(itemPath);
if (!targetItem) {
throw "target TreeItem is not exist in this itemPath " + itemPathString;
}
this.item = targetItem; // 步骤间变量传递
});
步骤:将目标树节点展开到可视范围内
将目标滚动到可视范围内,首先想到的就是scroll
相关的方法,而由于在上一步中已经取得了目标节点的对象,因此我们选择使用scrollIntoView()
方法将目标节点移至可视范围,该方法会自动的展开树节点,直到抵达目标节点。
Then("将目标树节点展开到可视范围内", async function () {
let targetItem = this.item;
await targetItem.scrollIntoView();
});
步骤:选中目标树节点并验证
当然CukeTest也提供树节点控件TreeItem
的展开expand()
操作方法,以及获取展开状态的expanded()
属性方法。因此我们可以在选中展开目标节点后,使用断言判断目标节点是否被正确展开:
Then("选中目标树节点并验证", async function () {
let targetItem = this.item;
// 如果目标不在可点击区域内则会展开到该节点位置
await targetItem.scrollIntoView();
await targetItem.expand();
let isChecked = await targetItem.expanded();
assert.equal(isChecked, true, "没有选中目标树节点");
});
场景: 根据文件路径展开并选中目标树节点
与上面的步骤——目标树节点的itemPath为{string},获取该树节点的对象
不同,这里我们希望像普通文件系统一样的去操作DirView应用,只需要传入文件的路径,就能展开各层文件夹节点,直到目标文件节点。而树和树节点控件都提供了findItem()
方法,可以在通过节点名称搜索符合条件的节点。
这种方式还有另一个优势,在这个步骤中,匹配节点所使用的是节点的名称,相比于使用itemPath
属性,节点的位置即使会变化也不会影响结果。
跨平台支持
为了让这个操作能够在各种操作系统中运行,需要引入一些库来处理系统间的差异,比如Windows的根目录往往是磁盘符(比如C:
)而Linux根目录就是/
;又比如Windows的文件路径使用反斜杠\
分隔,而Linux用斜杠\
。
解决系统间差异的最好方式是引入path
库处理路径,在脚本头部加入path
库的引用。
const path = require('path');
let relativePath = './'; // 当前路径(相对路径)
let absolutePath = path.resolve(__dirname, '..', relativePath); // 转换成绝对路径
let dirNamePath = absolutePath.split(path.sep); // 切分成数组
console.log(dirNamePath); // 结果为:["C:", "Users", "user", ...]
展开{string}文件所在树节点
由于路径包含多层文件夹,需要一层一层的展开,因此这一步骤可以拆解成以下几步:
- 在当前层级中搜索目标节点;
- 搜索到后展开该节点,进入下一层级;
- 重复1-2操作直到搜索不到目标节点后结束。
JavaScript
Given("展开到{string}文件所在树节点", async function (relativePath) { // 将路径拆分成路径节点数组,结果类似["C:", "Users", "user", ...] let dirNamePath = path.resolve(__dirname, '..', relativePath).split(path.sep); this.attach(`pathNodes: [${dirNamePath}]`); let tree = model.getTree('Dir_View'); let targetItem; // 由于路径根节点需要特殊处理 // Windows系统中为磁盘名+磁盘符 // Linux系统中为`/`,在pathNodes中表现为空字符'' let root = dirNamePath[0]; if (root === '') { targetItem = await tree.findItem('/'); } else { // 根节点为磁盘符时定位根节点 let rootNodeList = await tree.children(); await Promise.all(rootNodeList.map(async node => { let nodeName = await node.value(); if (nodeName.indexOf(root) !== -1) { targetItem = node; } })); } await targetItem.expand(); await Util.delay(200); await targetItem.scrollIntoView(); // 处理完根节点应该继续从第二个节点展开 for (let i = 1; i < dirNamePath.length; i++) { targetItem = await targetItem.findItem(dirNamePath[i]); if (!targetItem) { throw `Can not find the Item named ${dirNamePath[i]}.` } await targetItem.expand(); await Util.delay(200); // 为展开动画预留的时间 await targetItem.scrollIntoView(); } this.item = targetItem; // 在场景中传递TreeItem对象 });
其它脚本
步骤: 应用截图
在场景运行结束后对应用进行截图,以附件的形式贴到运行结果报告中显示,方便观察运行结果。涉及world对象以及报告附件的知识,可以点击链接深入学习。
Then("应用截图", async function () {
let screenshot = await model.getTree("Dir_View").takeScreenshot();
this.attach(screenshot, 'image/png')
});
将截图当作步骤和像演练:操作Qt应用中的List一样放进
hooks.js
文件中,两个都可以生效,但是放到生命周期里明显更简洁一点。
引入Hooks
Hooks即CukeTest项目的生命周期(也有直译为钩子)方法的定义,具体可以了解Hooks钩子。熟练的使用hook可以帮助测试人员少写很多重复性的代码,但同样的可能也会损失部分的可读性。hook.js
文件定义如下。这里设置了在项目运行开始前最小化CukeTest完毕后恢复CukeTest,可以避免CukeTest遮挡了运行结果和截图。
const { CukeTest } = require('cuketest');
const { After, AfterAll, BeforeAll, setDefaultTimeout } = require('cucumber');
const {Util} = require('leanpro.common');
/// 超时时间和Hook设置 ///
setDefaultTimeout(30 * 1000); //set step timeout to be 30 seconds
let proc;
BeforeAll(async function () {
CukeTest.minimize();
let extension = (process.platform === 'win32') ? '.exe' : '';
proc = await QtAuto.launchQtProcessAsync(path.dirname(process.execPath) + "/bin/dirview" + extension);
await model.getApplication("dirview").exists(10);
})
After(async function () {
let screenshot = await model.getTree("Dir_View").takeScreenshot();
this.attach(screenshot, 'image/png');
await Util.delay(2000);
})
AfterAll(async function () {
CukeTest.restore();
CukeTest.maximize();
Util.stopProcess(proc)
})
运行结果
总结
对于Qt树控件有很多嵌套、节点,是一个比较复杂的控件,在自动化的时候,应该要考虑到目标树产生变化的可能性,来写出足够稳定的自动化脚本,因为稳定才是自动化的生命线。通过CukeTest来进一步理解树结构还将进一步提高对树控件自动化的效率,本次演练针对的是比较典型的文件树应用,在实际生产中还会碰到更多运用树控件的应用,只要编写合适的脚本,CukeTest都可以完成对其成功的自动化。