【HarmonyOS 7 小艺 Skill 开发实战】 07 从平台评测到真机测试逐步定位Skill的问题
文章目录
前言
会后行动助手第一次生成时,报告显示三组用例全部通过。下载文件却把教学会议日期设成默认值,原文依据使用数组,建议项使用另一种字段名。修订以后,报告再次通过,实际输出的行动缺失字段仍然包含 meetingDate。如果只观察汇总数字,就无法发现程序后面读取结果时还会遇到什么问题。
排查时,需要把原始输入、预期与实际输出放在一起,沿执行顺序寻找最早出现的差异。文字整理阶段比较分类、负责人和日期;接入工具后,再核对调用参数、HTTP 返回与数据库内容;保存到设备时,则继续比较笔记。先确认前一个环节提供了什么,才能判断后一个环节在哪里改变了内容,避免看到最终答复错误就同时修改规则和服务。

一、打开固定输入,把预期与实际结果分开
打开 samples/07-verification/evaluation-cases.json,先选 N02。它要求保存一项截止日期明确、负责人尚未确定的工作,输入里保留蓝桥项目和 2026-10-04 会议日期。预期要求 owner=null、缺失字段包含 owner,技能询问负责人并且不创建提案。负责人缺失是这段原文的事实,与服务检查里故意把结构负责人设为空的请求不同。

图中的实际字段仍为 null,平台和设备状态均为 NOT_RUN。这个状态说明尚未执行,不能凭服务有字段校验就填写通过。真正运行以后,先保存完整答复和调用记录,再比较是否追问、有没有创建提案。只返回正确的空负责人,同时偷偷调用写入工具,也不满足这组预期。
比较 N01 和 N02,可以看到两种不同继续条件。N01 明确只整理,不保存,结果正确以后就应停止;N02 要求保存,但负责人没有确定,应该先补信息。它们都不应当产生写入,却各有原因。测试时保留原始请求,才能判断停止是遵守用户范围,还是因为缺少字段,不能只用没有行动新增作同一个结论。
日期缺失、建议、取消与重复也有各自固定输入。每一组把要观察的变化放在原文中,预期先写好,再取得实际返回。修改规则以后继续使用原输入,日期与姓名也保持相同,才能知道具体偏差是否消失。如果为了让新结果通过而同时改变测试文字,比较就失去原来的问题。
这个文件共有十五组,覆盖文字整理、查询、确认变化、取消、响应丢失与第二目标失败。数量来自当前业务风险,不代表已经执行十五次评测。查询需要真实工具和固定服务数据,端目标失败需要取得端工具,纯文字项目的空依赖无法完成这些调用。先区分用例需要什么条件,再选择能够执行的输入,其余继续保持未运行。
N13 和 N14 的输入描述了响应丢失与端目标失败,平台不会因为收到这句文字就自动制造对应故障。执行时需要在受控环境里先安排真实状态,保留服务或端工具的原始结果,再观察技能怎样核对和反馈。只让助手阅读一段失败说明,可以检查文字理解,不能证明它处理了真实异常。当前本机故障记录可以解释这些状态,仍然不能替代平台同一次调用。
二、返回历史生成会话,逐组打开报告中的输入与输出
在平台打开已有 meeting-actions 项目,进入对应的生成会话,再打开评测报告。当前代码区已经是修订内容,首次报告需要回到首次会话查看,并与初始下载文件配对。先检查报告属于哪一次生成,再读 With Skill 和 Without Skill,随后展开每组用例的输入、断言与实际输出。只打开当前源码,不能解释旧报告用了哪些规则。

首次三组有技能结果为 9/9、4/4、3/3,合计十六项断言;无技能分别为 7/9、1/4、3/3。界面汇总 100%±0 与 68%±39 表示该次报告的数值,十六项是判断项目数量,不能改成十六段不同会议。第三组无技能也全部通过,因此不能据此认定增加技能就改善了每一种日期判断。
展开断言以后,还要核对它检查的结构是否与业务约定一致。首次报告检查了分类、负责人缺失和相对日期,却没有指出教学值回填、source 数组和 suggestions.content 与后续读取方式之间的差异。符合生成器建立的判定,不一定符合程序采用的字段约定。先保存原始输出,再标出具体不同,才能决定还缺哪一组检查。
初始压缩包只有 SKILL.md,没有 module.json。首次页面同时显示验证、打包、上传、安全扫描通过和 BENIGN,这些是界面阶段结果,当前没有各阶段独立后台日志。包内容与报告应当分别读取,文件缺项也不能因为阶段通过被消除。初始文件没有版本配置,不给它套用其他本地维护文件的版本号。
然后回到同一项目的修订会话,打开新的三组报告。历史会话中现在可以读取缺少会议日期、建议与取消、重复归并三组 prompt,先读具体输入,再比较对应实际输出。完整原始请求尚未单独导出,当前可以回看会话,也保留了报告可见输出文件。需要继续记录时直接使用可见原文,不能依据输出中的 source 倒造整段请求。

这张 HTML 汇总显示有技能 100%±0,无技能 33%±58;已经保存的历史聊天汇总显示无技能 33%±0。两种视图的数值存在差异,当前保留各自原始记录,不推断差异的计算原因,也不把其中一份覆盖成另一份。判断规则有没有满足业务约定时,继续比较实际字段与断言。


图中三组输入与输出来自同一份历史修订会话。代码框存在横向滚动,完整字段仍以相应原始输出为准,不能只根据断言分数判断字段合规。
修订后每组三项断言通过,共九项,实际包增加了 module.json,版本为 0.2.0,依赖为空。元信息未知时使用空值,source 改为字符串,建议与取消采用 title。这些变化可以从导出文件直接核对,但包与本地维护的同版本文件字节不同,不能继承另一份文件的检查结果。同版本号只帮助识别阶段,仍要确认实际内容。
三、从真实字段偏差确定下一次修改位置
打开修订第一组的实际输出,当前保存位置为 samples/02-platform-revised/01-actual.json。根部三个会议元信息为 null,行动里张磊负责完成方案初稿,dueDate 也为空。原文依据保留会后一周内的表达,没有会议日期就无法算出明确截止时间,这个空值可以直接与规则比较。再展开 missingFields,可以看到 dueDate 与 meetingDate 同时出现。

当前业务约定只允许行动缺失数组出现 owner 与 dueDate。会议日期是根部信息,不能混进行动缺失字段,因此这份返回仍然有具体偏差。不要修正截图或实际 JSON,再把改好的内容称为模型结果。先保留这次输出,明确指出哪个字段超出范围,下一次才有可以比较的问题。
继续打开平台导出的修订 SKILL.md,查找输出约定与冲突处理要求。文件没有完整限制行动缺失字段的取值,冲突规则还允许在这里放入澄清内容。这两处都可能扩大字段含义,因此修订要求需要写清允许值,以及根部缺失信息应该放在哪里。只要求结果更准确,生成器仍然不知道应该删除哪个值,又该怎样保留必要提示。
下一次调整可以明确行动缺失数组仅使用 owner 和 dueDate,会议日期缺失与冲突提示放在 JSON 外说明,再使用相同输入核对。当前报告没有展示已经追问会议日期的连续对话,不能把一个空日期输出写成追问已经执行。需要确认追问时,应当继续保存对话中的问题和用户回答,再比较补充日期后的结果。
四、工具接入以后,沿真实请求找到第一处不同
云工具绑定完成以后,比较对象延伸到实际调用。用 N08 的蓝桥未完成查询输入,先看是否触发目标技能,再看工具有没有被调用。工具参数应当包含 blue-bridge 和 open,服务收到的地址也要保留这两个值。服务请求正确以后,再比较 HTTP 响应中的行动与最终清单,避免在没有调用记录时猜测数据来自哪里。
平台没有发出请求时,先检查真实资源依赖、地址可达与访问条件。模型已经给出正确参数,服务收到的参数却不同,检查工具输入映射;模型本来就选了 all,则回到状态描述与用户范围。服务按 all 返回已完成行动属于实际参数的结果,不能先把查询代码改成永远只返回未完成。

图中紫色圆点举例标出输入到工具参数之间的差异。实际排查时,要沿同一次请求找到最早不符合预期的位置,再核对后一环节是否保留了正确值;图中的位置不代表当前已经取得一条完整平台调用。
本地查询检查已经有独立结果。下面的代码来自真实 HTTP 检查程序,两组输入与预期先固定,再发送请求,不从响应临时生成判断。它可以作为比较服务行为的起点,但没有让模型识别自然语言,也没有经过云插件。
status, r = request('GET', '/api/actions?projectId=blue-bridge&status=open')
check('Q01 正常查询两个未完成行动', status == 200 and [i['id'] for i in r['items']] == ['A-001', 'A-002'])
status, r = request('GET', '/api/actions?projectId=empty-project&status=open')
check('Q02 合法空结果', status == 200 and r['items'] == [])


两张图来自同一份历史检查文件,前一张包含查询、批准和幂等结果,后一张接着展示故障、第二目标、重启与过期检查。文件末行同时说明这些结果没有经过平台、云插件、备忘录或设备。因此,二十六项本地检查可以帮助理解服务怎样处理请求,却不能填入十五组独立输入的平台实际结果栏。
服务结果中的正常查询、合法空结果和错误参数分别解释当前接口行为。平台调试返回空清单时,先确认 HTTP 是否为 200;如果实际是 INVALID_STATUS,最终反馈就不能说没有行动。结果页可以比较数据库内容,请求响应则说明筛选参数,两项结合才能知道是不是读对了对象。这里读取的是已经保存的请求与检查结果。
下表把最早出现差异的位置与需要查看的记录放在一起。排查时先确认前一环节的值,再检查下一环节是否仍然保留它,而不是看到最终错误就同时修改多个位置。这个顺序来自接口与业务排查经验,目的是让每次改动都能对应一处已经观察到的问题。
| 第一处不同 | 要比较的实际记录 | 当前修改方向 |
|---|---|---|
| 没有触发目标技能 | 固定输入、入口与技能文件 | 触发描述或测试对象 |
| 负责人、日期或分类不符 | 原文与完整结构化输出 | 业务规则与允许字段 |
| 模型工具参数不符 | 用户请求与实际调用参数 | 工具描述和参数选择 |
| 服务参数与工具输入不符 | 工具参数与原始HTTP请求 | 插件输入映射 |
| HTTP返回错误 | 状态、错误码与部署条件 | 接口输入、访问权限或可达性 |
| 数据正确而反馈不符 | 操作结果与最终答复 | 返回整理和状态说明 |
| 端回执与笔记不同 | 实际工具返回与设备记录 | 端工具、权限与设备条件 |
修正第一处以后,继续观察同一次输入的完整结果。项目修正以后,助手仍然可能漏掉许岚的任务,这时第二个偏差已经落在结果整理。保留原始输入与前后输出,就可以分别说明两个问题,不把其中一项修复当成整次调用已经正确。新失败有自己的记录,再决定是否需要增加针对它的检查。
五、用部分成功记录检查反馈,避免重新写入已完成业务
业务提交以后,先比较操作查询与最终答复。历史响应丢失检查中,客户端没有 HTTP 状态,操作查询却取回 committed 和行动标识。服务已经发生的变化由该操作结果说明,不能因为工具提示连接断开就反馈没有保存。这里检查的是技能是否读对结果和说对状态,事务怎样实现已经在提交服务中明确。
打开历史请求响应的第五十条,可以看到同一操作的第二目标状态。主操作仍然为 committed,localCopy 为 failed,错误说明测试目标明确拒绝保存。前一份查询里副本还没有请求,后来独立目标明确失败,两个时间点的状态需要分别保留。用户最终应该看到业务已完成以及副本未完成,不能只收到一句全部失败。

这张图的 source=local_test_destination 指向独立 SQLite 测试库,它没有调用手机备忘录。服务保留了各目标状态,所以可以用它检查反馈怎样区分部分成功,但十五组中的 N14 仍然等待真实端工具执行。不能把本地失败文字复制进设备实际栏,再宣称完成了备忘录错误处理。
如果真实业务完成而端工具失败,接着只判断副本是否需要补做,不能重新创建业务提案。端工具超时且没有查询标识时,副本仍然未知,应先核对设备内容。业务服务的幂等结果不能证明笔记没有重复,设备中的标题、正文和条数需要自己的观察。把目标分别比较以后,用户才能知道哪项完成、哪项等待核对。
六、打开真机入口,按实际页面核对账号与版本条件
在当前 Vibe 项目打开真机测试入口,先阅读 Claw 与主对话两个入口的说明。本次页面给出 Claw 安装提示,主对话要求 HarmonyOS 7、小艺 11.7.x 或更高,并提示到白名单配置用户。页面还显示主对话测试十五天期限,沙箱默认否。当前只查看了这些说明,没有发布测试、安装技能或修改白名单,符合条件设备上的调用仍然等待执行。




先检查目标设备系统与小艺版本,再核对当前入口要求的测试身份。账号不在允许范围时,设备可能看不到技能,不能把入口缺失直接归给触发描述。设备条件满足以后,还需要确认正在测试的技能内容与工具资源版本,避免设备会话仍使用旧文件。包版本相同但字节不同的两份技能,也应通过实际内容或包摘要区分。
开发界面的另一种真机流程使用测试用户组。团队管理员在开发界面选择用于测试的群组,把测试用户加入对应范围,再发布到主对话或 Claw。这个流程最多一百组,每组一百位用户,每次最多勾选十组,发布有效期十五天。当前 Vibe 白名单说明与它的展示不同,不能照着用户组模板填写就假定白名单已经配置。

当前账号已有一组测试用户,人数为一,页面显示当前账号属于这个组。这个草稿的组开关仍然关闭,没有更改原组成员,也没有发布真机测试。只有将正确的用户范围用于当前技能并实际发布测试以后,才继续检查设备能否发现目标版本。
用户组流程允许重新发布或取消测试,内容调整以后需要确保设备取得目标版本。测试到期也可能导致技能不可见,因此记录发布时间与有效期限能够排除一项具体原因。当前没有真机测试发布记录,不填写虚构发布日期。公共上架与测试入口也有不同动作,打开真机说明不表示已经完成公开审核。
打开 samples/07-verification/device-record-template.md,当前状态保持未运行。模板把身份、技能包、工具、设备和服务条件放在一次记录里,执行时用真实信息补齐,账号和凭据需要脱敏。里面的用户组项对应那一种开发流程,进入 Vibe 白名单页面时应按实际入口说明记录,不能把两种配置当成同一操作。

七、在同一设备逐项比较输入、调用和笔记
条件满足以后,先用 N01 检查只整理范围,保存原始输入、完整结构与没有写入的调用记录。然后选择负责人缺失的 N02,确认空值和追问,再观察是否停止提案。输入中的会议日期保持固定,不能用设备今天替代。每一组都保存当前技能与资源身份,避免一次结果对应另一份实现。
查询工具已经接好时,再使用正常与空结果输入,比较工具参数、HTTP 响应和最终清单。创建与提交完成接入以后,再检查确认前取消、内容变更及响应丢失。内容变更需要看到新提案与重新批准,只有按钮显示正确还不足以说明提交内容正确。真实操作记录能把预览、批准和写入连到同一份清单。
端工具测试需要打开设备里的真实备忘录,比较标题、正文和条数。工具返回成功以后,仍然确认陈禾的日期待确认、建议与取消是否保留,原文是否完整。确认前取消时,不应出现新的保存调用;用户改负责人以后,先观察新预览再确认。工具失败与超时则分别按明确结果和待核对处理。
总结
两轮报告中的实际输出已经指出一处明确问题:行动的 missingFields 多出了 meetingDate。修改应先针对这项字段约束,再用相同输入重新核对,不能用另一轮报告的百分比代替比较。工具和设备接入后继续沿用同一方法,将参数、服务结果和笔记依次对应,找到最先偏离预期的位置。
十五组独立输入仍需在具备相应身份、版本和资源的环境中执行。测试时应分别记录业务成功、目标失败和结果未知,取消反馈也要与已经发生的写入一致。
完整文件
1. 十五组固定输入与预期
文件路径:samples/07-verification/evaluation-cases.json。
这份文件保存 N01 至 N15 的完整输入、预期和实际结果栏。复用某组输入时,保留原文中的日期、姓名和操作范围;需要查询、批准或端工具的用例,还要先满足相应条件。
{
"meetingDate": "2026-10-04",
"timezone": "Asia/Shanghai",
"data": "fictional",
"cases": [
{
"id": "N01",
"risk": "只整理文字",
"input": "蓝桥,会议日期2026-10-04。林舟在10月8日前确认接口字段。请只整理,不保存。",
"expected": "actions含林舟/2026-10-08和原文依据;没有任何写入工具调用",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N02",
"risk": "缺少负责人",
"input": "蓝桥,会议日期2026-10-04。10月8日前确认接口字段,负责人还没有确定。请保存。",
"expected": "owner=null;missingFields含owner;询问负责人;不创建提案",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N03",
"risk": "缺少截止日期",
"input": "蓝桥,会议日期2026-10-04。林舟负责确认接口字段,日期尚未确定。请保存。",
"expected": "dueDate=null;missingFields含dueDate;不猜日期;不创建提案",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N04",
"risk": "建议尚未行动",
"input": "蓝桥,会议日期2026-10-04。许岚建议增加周报功能,大家决定下次讨论。",
"expected": "进入suggestions,不写入actions",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N05",
"risk": "取消已讨论事项",
"input": "蓝桥,会议日期2026-10-04。原来讨论的短信提醒功能取消,不安排实现。",
"expected": "进入cancellations,不生成新行动",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N06",
"risk": "重复归并",
"input": "蓝桥,会议日期2026-10-04。林舟在10月8日前确认接口字段。会议结束前重申,接口字段仍由林舟在10月8日前确认。",
"expected": "仅一项行动,保留原文和归并说明",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N07",
"risk": "项目不明确",
"input": "会议日期2026-10-04。林舟在10月8日前确认接口字段。帮我保存。",
"expected": "询问项目,不默认写blue-bridge",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N08",
"risk": "正常查询",
"input": "查询蓝桥项目未完成行动,不新增。",
"expected": "只读projectId=blue-bridge,status=open;返回A-001与A-002",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N09",
"risk": "合法无结果",
"input": "查询empty-project未完成行动。",
"expected": "items为空时说明没有匹配项,不编造记录,不写入",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N10",
"risk": "查询失败",
"input": "查询蓝桥行动,测试工具返回HTTP400 INVALID_STATUS。",
"expected": "保留工具失败及错误码,不解释为项目没有行动",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N11",
"risk": "确认前取消",
"input": "创建完整行动提案后,我取消这次保存。",
"expected": "取消未提交提案;不提交;后续业务行数不增加",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N12",
"risk": "内容改动",
"input": "已生成提案后,将负责人从林舟改为许岚。",
"expected": "新建提案并重新核对批准,不复用旧批准",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N13",
"risk": "提交响应丢失",
"input": "已批准的提案提交时连接断开,但服务端已提交。",
"expected": "使用已知operationId核对;committed时取回原结果,不重复新增",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N14",
"risk": "独立目标失败",
"input": "业务提交成功,备忘录工具明确失败。",
"expected": "分别说明业务已完成和备忘录未完成;不回滚业务,不虚构笔记",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
},
{
"id": "N15",
"risk": "备忘录结果未知",
"input": "备忘录工具超时,没有可查询的操作标识。",
"expected": "提示结果待核对,用户在设备检查后再决定重试;不自动重新保存",
"platformStatus": "NOT_RUN",
"platformActual": null,
"deviceStatus": "NOT_RUN",
"deviceActual": null
}
]
}
当前各组的平台与设备状态均为 NOT_RUN,实际结果仍为 null。输入中描述响应丢失或工具失败,不会自动制造对应故障,执行时仍需取得真实调用与返回。
2. 平台与设备操作记录模板
文件路径:samples/07-verification/device-record-template.md。
这份模板把设备条件、技能版本、工具参数、服务变化和笔记内容放在同一次记录中。实际执行时按当前入口填写身份条件;模板中的用户组字段不能代替 Vibe 页面要求的白名单核对。
# 平台与目标设备实测记录模板
当前状态 NOT_RUN;未发布真机测试,未操作公开审核。
## 测试身份与版本
- 日期与时间:待实际操作
- Skill版本及包摘要:待实配
- 所引用插件/工具ID和版本:待实配
- 目标设备型号、HarmonyOS版本、小艺版本:待实测
- 测试账号是否在选定用户组:待核对,不保存真实手机号
- 真机测试发布日期/15天有效期截止:待实配
- 发布入口:小艺主对话 / 小艺Claw,分别记录
- HTTPS服务部署版本及批准页面:待部署
- 备忘录工具真实名称、参数及权限状态:待详情核对
## 每组用例
| 字段 | 记录 |
|---|---|
| 固定用例ID | 待执行 |
| 原始用户输入 | 从evaluation-cases.json逐字复用 |
| 实际触发的Skill | 待实际记录 |
| 工具调用顺序和参数 | 待实际记录 |
| HTTP请求/响应标识 | 待实际记录,凭据脱敏 |
| proposalId/operationId | 待实际记录 |
| 用户实际批准/取消时间 | 待实际记录 |
| 数据库或结果页实际变化 | 待实际记录 |
| 设备中笔记标题、内容及条数 | 待实际记录 |
| 对话最终反馈 | 待实际记录 |
| 与预期差异 | 待判断 |
| 首个偏差发生环节 | 触发 / 提取 / 参数映射 / 云接口 / 批准 / 端权限 / 设备执行 / 版本 |
| 当前结论 | PASS / FAIL / BLOCKED / NOT_RUN,不能留空 |
| 保留的截图或报告文件 | 待实际生成;不使用示意图证明成功 |
## 平台评测
进入可选评测时保存自动生成测试用例、真实工具调用结果及Canvas报告,再与15组独立业务用例比较。自动报告中没有运行的取消、超时和端目标情况仍是缺项,不填为PASS。
## 发布条件检查
团队测试组管理需要管理员;最多100组,每组100人,一次最多勾选10组;每次真机测试发布有效15天。过期后重新发布时重新核对目标版本。管理界面当前值与保存记录不符时,以实际界面结果另行记录。公开审核和真机发布分别取得授权,模板不表示已经执行。
模板中的待填写项目保留原状。只有实际完成对应操作并保存记录以后,才能填写结果,不能把本地接口检查填入设备执行栏。
3. 保留字段偏差的平台原始输出
文件路径:samples/02-platform-revised/01-actual.json。
第三节讨论的实际输出完整保留如下。行动的 missingFields 同时包含 dueDate 和 meetingDate,后者超出了当前行动缺失字段约定;比较规则调整前后的结果时,需要保留这份原始内容。
{
"projectId": null,
"meetingId": null,
"meetingDate": null,
"actions": [
{
"title": "完成方案初稿",
"owner": "张磊",
"dueDate": null,
"source": "张磊负责在会后一周内完成方案初稿。",
"missingFields": [
"dueDate",
"meetingDate"
]
}
],
"suggestions": [],
"cancellations": []
}
根部会议信息为空,以及行动缺失字段多出 meetingDate,是需要分别比较的两件事。这份输出没有记录连续追问,不能据此判断平台已经询问并取得会议日期。
4. 本地 HTTP 与数据库检查程序
文件路径:examples/demo-service/test_service.py。
这份程序通过本机 HTTP 和 SQLite 检查查询、批准、并发提交、响应丢失及重启后的结果。程序启动时会删除 test-results/ 中匹配 actions.sqlite* 的文件,并清空请求与服务记录;需要保留历史结果时,应先另存该目录,再在复制的工程中执行。
#!/usr/bin/env python3
"""Real HTTP + SQLite tests. UI automation here is a local server test, not a human/device result."""
import concurrent.futures
import copy
import http.client
import json
import re
import shutil
import sqlite3
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
BASE = Path(__file__).resolve().parent
OUT = BASE / 'test-results'
OUT.mkdir(exist_ok=True)
DB = OUT / 'actions.sqlite'
for p in OUT.glob('actions.sqlite*'):
p.unlink()
for p in ('requests-responses.jsonl', 'server-audit.jsonl'):
(OUT / p).write_text('')
LOCK = threading.Lock()
RESULTS = []
def start(test=True, ttl=600):
command = [sys.executable, str(BASE / 'server.py'), '--port', '0', '--database', str(DB),
'--audit', str(OUT / 'server-audit.jsonl'), '--proposal-ttl', str(ttl)]
if test:
command.append('--test-mode')
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
line = process.stdout.readline().strip()
if not line.startswith('LISTENING '):
raise RuntimeError(line + process.stderr.read())
return process, line.split(' ', 1)[1]
def stop(process):
process.terminate()
process.wait(timeout=10)
def request(method, path, body=None, headers=None, form=False):
data = urllib.parse.urlencode(body).encode() if form else json.dumps(body, ensure_ascii=False).encode() if body is not None else None
hd = {'Content-Type': 'application/x-www-form-urlencoded' if form else 'application/json'}
hd.update(headers or {})
req = urllib.request.Request(URL + path, data=data, method=method, headers=hd)
raw, status = None, None
try:
with urllib.request.urlopen(req, timeout=5) as response:
status, raw = response.status, response.read().decode()
except urllib.error.HTTPError as e:
status, raw = e.code, e.read().decode()
except (http.client.RemoteDisconnected, urllib.error.URLError, ConnectionResetError) as e:
raw = {'clientError': type(e).__name__, 'message': str(e)}
value = json.loads(raw) if isinstance(raw, str) and raw.startswith('{') else raw
with LOCK:
with (OUT / 'requests-responses.jsonl').open('a') as f:
f.write(json.dumps({'request': {'method': method, 'path': path, 'headers': hd, 'body': body},
'response': {'status': status, 'body': value}}, ensure_ascii=False) + '\n')
return status, value
def check(name, condition):
if not condition:
raise AssertionError(name)
RESULTS.append({'case': name, 'result': 'PASS'})
print('PASS ' + name)
def payload(title='确认教学清单'):
return {'projectId': 'blue-bridge', 'meetingId': 'M-20261004-01', 'meetingDate': '2026-10-04',
'actions': [{'title': title, 'owner': '林舟', 'dueDate': '2026-10-08', 'source': '林舟在八日前确认教学清单。', 'missingFields': []}]}
def proposal(title='确认教学清单'):
status, p = request('POST', '/api/proposals', payload(title))
assert status == 201, p
return p
def approve(p):
status, page = request('GET', p['approvalUrl'])
assert status == 200 and '<table>' in page
token = re.search(r'name="token" value="([^"]+)"', page).group(1)
status, _ = request('POST', '/ui/proposals/' + p['proposalId'] + '/approve', {'token': token}, form=True)
assert status == 200
return token
def count():
with sqlite3.connect(DB) as c:
return c.execute('SELECT count(*) FROM actions').fetchone()[0]
PROCESS, URL = start()
try:
status, r = request('GET', '/api/actions?projectId=blue-bridge&status=open')
check('Q01 正常查询两个未完成行动', status == 200 and [i['id'] for i in r['items']] == ['A-001', 'A-002'])
status, r = request('GET', '/api/actions?projectId=empty-project&status=open')
check('Q02 合法空结果', status == 200 and r['items'] == [])
status, r = request('GET', '/api/actions?projectId=blue-bridge&status=unknown')
check('Q03 状态错误与空结果区分', status == 400 and r['error']['code'] == 'INVALID_STATUS')
status, r = request('GET', '/api/actions?projectId=blue-bridge&projectId=other')
check('Q04 重复Query拒绝', status == 400)
status, page = request('GET', '/')
check('Q05 结果页显示真实数据库内容', status == 200 and '核对接口字段' in page and '整理验收清单' in page)
p = payload()
p['actions'][0]['owner'] = None
p['actions'][0]['missingFields'] = ['owner']
status, r = request('POST', '/api/proposals', p)
check('W01 缺失负责人不创建提案不写入', status == 422 and r['error']['code'] == 'MISSING_INFORMATION' and count() == 3)
p = proposal()
status, r = request('POST', '/api/commits', {'proposalId': p['proposalId'], 'idempotencyKey': 'key_confirmed_01', 'confirmed': True})
check('W02 confirmed字段不授权', status == 400 and count() == 3)
status, r = request('POST', '/api/commits', {'proposalId': p['proposalId'], 'idempotencyKey': 'key_unapproved_01'})
check('W03 未批准提交拒绝', status == 403 and count() == 3)
status, public = request('GET', '/api/proposals/' + p['proposalId'])
check('W04 工具提案响应无批准令牌', 'token' not in json.dumps(public))
status, r = request('POST', '/ui/proposals/' + p['proposalId'] + '/approve', {'token': 'bad'}, form=True)
check('W05 无结果页令牌不能批准', status == 403)
token = approve(p)
status, r = request('POST', '/ui/proposals/' + p['proposalId'] + '/approve', {'token': token}, form=True)
check('W06 批准令牌单次使用', status == 409)
commit_body = {'proposalId': p['proposalId'], 'idempotencyKey': 'key_parallel_01'}
with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor:
parallel = list(executor.map(lambda _: request('POST', '/api/commits', commit_body), range(8)))
success = parallel[0][1]
check('W07 八并发同键同参返回同结果且仅写一次', all(s == 200 and b == success for s, b in parallel) and count() == 4)
p2 = proposal('另一提案')
approve(p2)
status, r = request('POST', '/api/commits', {'proposalId': p2['proposalId'], 'idempotencyKey': 'key_parallel_01'})
check('W08 同键异参409且无额外写入', status == 409 and r['error']['code'] == 'IDEMPOTENCY_CONFLICT' and count() == 4)
status, r = request('POST', '/api/commits', {'proposalId': p['proposalId'], 'idempotencyKey': 'another_key_01'})
check('W09 新键不能重复消耗同一批准', status == 409 and count() == 4)
canceled = proposal('取消行动')
status, r = request('POST', '/api/proposals/' + canceled['proposalId'] + '/cancel', {})
status2, _ = request('POST', '/api/commits', {'proposalId': canceled['proposalId'], 'idempotencyKey': 'key_canceled_01'})
check('W10 确认前取消阻止提交', status == 200 and r['status'] == 'canceled' and status2 == 409 and count() == 4)
approved_cancel = proposal('批准后取消')
approve(approved_cancel)
status, r = request('POST', '/api/proposals/' + approved_cancel['proposalId'] + '/cancel', {})
status2, _ = request('POST', '/api/commits', {'proposalId': approved_cancel['proposalId'], 'idempotencyKey': 'key_canceled_02'})
check('W11 批准后提交前取消阻止写入', status == 200 and status2 == 409 and count() == 4)
status, r = request('POST', '/api/proposals/' + p['proposalId'] + '/cancel', {})
check('W12 提交后取消不撤回', status == 409 and count() == 4)
failed = proposal('明确失败行动')
approve(failed)
fail_body = {'proposalId': failed['proposalId'], 'idempotencyKey': 'key_failure_01'}
status, r = request('POST', '/api/commits', fail_body, {'X-Test-Fault': 'fail_before_write'})
status2, r2 = request('POST', '/api/commits', fail_body)
check('F01 明确失败保存同键失败结果且无业务写入', status == status2 == 503 and r == r2 and count() == 4)
status, r = request('GET', '/api/operations/' + failed['operationId'])
check('F02 查询明确失败操作', status == 200 and r['status'] == 'failed')
lost = proposal('响应丢失行动')
approve(lost)
lost_body = {'proposalId': lost['proposalId'], 'idempotencyKey': 'key_lost_reply_01'}
status, r = request('POST', '/api/commits', lost_body, {'X-Test-Fault': 'drop_after_commit'})
status2, op = request('GET', '/api/operations/' + lost['operationId'])
check('F03 提交后响应丢失仍可查询已提交', status is None and status2 == 200 and op['status'] == 'committed' and count() == 5)
status, r = request('POST', '/api/commits', lost_body)
check('F04 响应丢失后同键取回同一结果', status == 200 and r == op['result'] and count() == 5)
status, r = request('POST', '/__test/local-copies', {'operationId': lost['operationId'], 'outcome': 'failed'})
status2, op2 = request('GET', '/api/operations/' + lost['operationId'])
check('F05 独立目标失败保留业务提交和失败状态', status == status2 == 200 and op2['status'] == 'committed' and op2['localCopy']['status'] == 'failed' and count() == 5)
status, r = request('POST', '/__test/local-copies', {'operationId': p['operationId'], 'outcome': 'saved'})
check('F06 独立测试目标实际保存内容', status == 200 and r['localCopy']['status'] == 'saved' and r['localCopy']['content']['actions'][0]['title'] == '确认教学清单')
stop(PROCESS)
PROCESS, URL = start()
status, r = request('POST', '/api/commits', commit_body)
status2, restarted = request('GET', '/api/operations/' + lost['operationId'])
check('P01 重启后业务幂等操作及独立目标均持久化', status == 200 and r == success and status2 == 200 and restarted['status'] == 'committed' and restarted['localCopy']['status'] == 'failed' and count() == 5)
stop(PROCESS)
PROCESS, URL = start(test=False)
status, r = request('POST', '/api/commits', lost_body, {'X-Test-Fault': 'drop_after_commit'})
status2, _ = request('POST', '/__test/local-copies', {'operationId': lost['operationId'], 'outcome': 'saved'})
check('S01 普通模式拒绝故障注入和测试目标', status == 403 and status2 == 403)
stop(PROCESS)
PROCESS, URL = start(ttl=1)
expires = proposal('过期行动')
approve(expires)
time.sleep(1.1)
status, r = request('POST', '/api/commits', {'proposalId': expires['proposalId'], 'idempotencyKey': 'key_expired_01'})
status2, r2 = request('GET', '/api/operations/' + expires['operationId'])
check('S02 过期批准拒绝提交并可核对', status == 409 and r['error']['code'] == 'PROPOSAL_EXPIRED' and status2 == 200 and r2['status'] == 'expired' and count() == 5)
with sqlite3.connect(DB) as c:
counts = {table: c.execute('SELECT count(*) FROM ' + table).fetchone()[0] for table in ['actions', 'proposals', 'operations', 'idempotency']}
manifest = {'checkedAt': '2026-10-04', 'python': sys.version, 'transport': 'real 127.0.0.1 HTTP', 'storage': 'real SQLite plus independent copies SQLite',
'cases': RESULTS, 'databaseCounts': counts, 'platform': 'NOT_RUN', 'cloudPlugin': 'NOT_BOUND', 'memoPlugin': 'NOT_BOUND', 'device': 'NOT_RUN'}
(OUT / 'results.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n')
(OUT / 'results.md').write_text('# 自建服务实际检查\n\n2026-10-04;真实 loopback HTTP 与 SQLite。\n\n' + '\n'.join('- ' + r['case'] + ' ' + r['result'] for r in RESULTS) + '\n\n平台、云插件、备忘录和设备尚未执行。\n')
finally:
if PROCESS.poll() is None:
stop(PROCESS)
程序通过读取本地页面令牌完成批准请求,这项操作用于检查服务,不能替代真实用户或设备批准。文件末尾的检查日期是原文件已有内容,重新执行时不会自动改成当天日期。
5. 本地服务的历史检查结果
文件路径:examples/demo-service/test-results/results.md。
这份文件保存 2026-10-04 的二十六项本地检查结果,与前面的十五组平台及设备用例分别记录。
# 自建服务实际检查
2026-10-04;真实 loopback HTTP 与 SQLite。
- Q01 正常查询两个未完成行动 PASS
- Q02 合法空结果 PASS
- Q03 状态错误与空结果区分 PASS
- Q04 重复Query拒绝 PASS
- Q05 结果页显示真实数据库内容 PASS
- W01 缺失负责人不创建提案不写入 PASS
- W02 confirmed字段不授权 PASS
- W03 未批准提交拒绝 PASS
- W04 工具提案响应无批准令牌 PASS
- W05 无结果页令牌不能批准 PASS
- W06 批准令牌单次使用 PASS
- W07 八并发同键同参返回同结果且仅写一次 PASS
- W08 同键异参409且无额外写入 PASS
- W09 新键不能重复消耗同一批准 PASS
- W10 确认前取消阻止提交 PASS
- W11 批准后提交前取消阻止写入 PASS
- W12 提交后取消不撤回 PASS
- F01 明确失败保存同键失败结果且无业务写入 PASS
- F02 查询明确失败操作 PASS
- F03 提交后响应丢失仍可查询已提交 PASS
- F04 响应丢失后同键取回同一结果 PASS
- F05 独立目标失败保留业务提交和失败状态 PASS
- F06 独立测试目标实际保存内容 PASS
- P01 重启后业务幂等操作及独立目标均持久化 PASS
- S01 普通模式拒绝故障注入和测试目标 PASS
- S02 过期批准拒绝提交并可核对 PASS
平台、云插件、备忘录和设备尚未执行。
更多推荐



所有评论(0)