# 〖共创稿事节〗HarmonyOS 7 新特性实战(69):真机复验检索工具升级——四道预检把白跑挡在数据侧

> 本篇是系列第 69 篇,对象是全书复验一直在用、却一直没转正的「翻页找串」工具。结论先说:四轮踩过的坑——导读抢先命中、停在篇末找不回头、预算 78 页小于目标 p135、非 BMP 字码点错位——这轮全部搬进工具的预检层,不碰设备就能回答「这一串能不能搜、在哪一页、预算够不够」,页位由应用真分页器推导,九条历史检索串「预估页 + 3 = 真机页」逐条对上。写作中还揪出一个新问题:跨郭璞夹注的检索串在设备文本属性里结构性不连续。收口后第 55 项门禁首跑通过、全量 55/55、HAP 构建 13.9 秒。

一、账本:四轮白跑,坑都出在工具层

先交代工具本身。全书复验的做法是:给一个古籍原文片段和一个页面预算,工具用 uitest 驱动设备翻页,逐页抓 dumpLayout 的文本属性找这串(uitest 官方文档把它归在命令行工具一节)。脚本一直躺在临时目录里,不入库、无校验,预算靠人拍脑袋。它踩过的坑按时间排:

轮次坑白跑的代价
第十轮(2026-10-13)坑 4非 BMP 字在 UTF-16 下标空间占 2 格对账管线下标错位,排查半天才定位
第十二轮(2026-10-14)教训①检索串被扉页导读抢先命中(犀渠、驕蟲都在导言里被点名)命中的不是目标页,以为内容丢了
第十二轮 教训②工具从当前页接着翻,停在篇末就永远找不回头部必须手动回卷首再跑一遍
第十六轮(2026-10-16)B5 补验页面预算 30+8+20+20=78 页,目标在 p135–1574 串全 MISS,第二轮才对上
本轮(2026-11-10)新发现跨郭璞夹注的检索串结构性命不中历史复验记录里混进了「假命中」

这些坑的共同点是:全都发生在设备跑起来之后。而判断它们的材料——语料数据——本来就在仓库里。串在导读里出现过,数据知道;目标在哪一页,分页器知道;串里有没有孤立代理对,码点扫描知道。所以这轮的改造思路是一句话:把「能不能跑」的判断从设备挪回数据,设备循环只做数据侧放行之后的事。

二、页位不再猜:加载应用真分页器

最值钱的是页位。旧工具的预算靠人估,第十六轮 78 对 135 的全 MISS 就是这么来的。新工具的预检直接加载应用的真分页器 ShanhaiChapterPagination(与内容门禁 verify-source-chapters 同一加载口径),把目标串定位到分页结果的具体一页:

// scripts/lib/e2e-search-precheck.mjs(节选)
// 一页的两套口径,别混:
//   nodes:设备 dumpLayout 可命中的最小文本单元(正文 Text 节点、译文、扉页字段)
//   readingFlow:夹注外正文大字流 + 译文 —— 串「在内容里」即可定位页
function pageTexts(page, ch) {
  if (page.kind === 'front') {
    const nodes = frontNodes(ch);
    return { nodes, readingFlow: nodes.map((n) => n.text).join('\u0001') };
  }
  const nodes = [];
  const bodyRuns = [];
  for (const para of page.paragraphs) {
    if (para.length > 0) nodes.push({ kind: 'source', label: '正文', text: para.map((r) => r.text).join('') });
    bodyRuns.push(para.filter((r) => !r.note).map((r) => r.text).join(''));
  }
  for (const t of page.translations) {
    if (t.length > 0) nodes.push({ kind: 'translation', label: '译文', text: t });
  }
  return { nodes, readingFlow: [...bodyRuns, ...page.translations].join('\u0001') };
}

拿历史账本验算。第十六轮真机复验的 9 条检索串(B5 补验 4 条 + B6 新验 5 条),逐条跑预检,与当年截图上的页码对账:

检索串预估页预估 + 3真机实测页分类
赤理黃華青柎,名曰帝女之桑128131p131single-node
俗謂之岣嶁山129132p132single-node(整串在夹注内)
見則其邑有火,名曰𤝻即130133p133single-node(非 BMP 串)
凡四十八山,三千七百三十二里136139p139single-node
曰篇遇之山,無草木,多黃金138141p141跨注拒跑(第四节)
帝之二女居之143146p146single-node
凡十五山,二千八百里150153p153single-node
大凡百九十七山,二萬一千三百七十一里152155p155single-node
是謂國用154157p157single-node

九条串「预估页 + 3 = 真机页」逐条成立——真机版心与分页器默认版心有固定偏差,3 就是校准常数,钉进门禁。分页不是估算出来的运气,是应用自己跑出来的事实:旧预算 78 页在数据侧一眼就能判死,根本不用上设备。

九条历史检索串的页位对账:预估 +3 与真机页逐条成立

三、唯一性闸:犀渠在数据里就是两处

第十二轮「导读抢先命中」的坑,根源是犀渠这个异兽在扉页导言里被点名,正文里又出现。跑一遍预检,数据侧直接复现:

$ node scripts/e2e-page-search.mjs --precheck "犀渠"
预检 [犀渠]
  语料 2026.10.18-v29-shanhaijing-hainei · 模式 both · 章节 《山海经·中山经》
  拒跑:《山海经·中山经》内有 2 处命中:p1(扉页·阅读导言)、p30(正文/译文)。
        换更长的串,或确认就要第一处时加 --force-first。

两处命中、每处的页位和来源面都报出来。工具语义也就清楚了:设备翻页会停在第一个命中的页,所以多页命中时要么换更长的串,要么用 --force-first 显式接受「要的就是第一处」——放行必须留警告,不许静默。这个设计里有一条底线:预检只在数据侧拒绝,一旦放行,设备上发生什么与预检结论的偏差就是渲染层问题,不再混入「串本身歧义」的变量。

预检管线:数据侧四道闸,设备循环只走契约

四、新发现:跨夹注串是结构性命不中的

写作中对账第九条历史串时抓到一个此前没人注意的问题。那段原文的 run 结构是:

正文段(一个 Text 节点,夹注是其中的 Span):
  run[0] body 「《中次十二經》洞庭山之首,曰篇遇之山」
  run[1] note 「或作肩」          ← 郭璞夹注,与 B6 复验同批入库(813145a)
  run[2] body 「,無草木,多黃金。」

合并文本(设备 Text 节点的属性口径):
  《中次十二經》洞庭山之首,曰篇遇之山或作肩,無草木,多黃金。
检索串:                              曰篇遇之山,無草木,多黃金   → 不连续

夹注插在「篇遇之山」和「無草木」中间,检索串在合并文本里永远不连续。对照第十六轮的复验记录:九条串里唯独这条没有工具命中的佐证,页位是靠截图 07 在屏留证的——「9/9 HIT」的账本里混了一条「截图在屏」,当年没人发现。预检现在会直接拒掉这类串,并给出不跨注的改串建议:

$ node scripts/e2e-page-search.mjs --precheck "曰篇遇之山,無草木,多黃金"
  拒跑:p138 的命中跨郭璞夹注 Span:正文与夹注同处一个 Text 节点,
        合并文本里夹注插在中间,dumpLayout 属性文本不含完整串。
        改用不跨注的片段:「無草木,多黃金」 或 「曰篇遇之山」

两条建议都是单节点连续,复检预估同在 p138。顺便把另一个坑也解释了:「俗謂之岣嶁山」整串躺在夹注 run 内部,单节点连续,所以当年 p132 能命中——夹注内可搜,跨夹注不可搜,这条边界如今成了工具的分类输出。

跨夹注串示意:夹注 Span 插在正文中间,合并文本不连续

五、起点状态与码点:两道契约

剩下两坑是纯纪律问题,就用契约钉死。起点状态:设备循环必须显式声明 --from-start(回翻到卷首,上限 260 次)或 --resume(从当前页继续,自担漏检风险),不带起点语义直接退出——第十二轮停在篇末的教训变成了硬门槛。码点安全:检索串先过孤立代理对扫描,输出预览按码点截断(Node.js 的 execFileSync 撑起设备调用层,文本处理层不用它操心编码):

// scripts/e2e-page-search.mjs(节选)
if (!fromStart && !resume) {
  console.error('起点状态必须显式声明:--from-start(翻回卷首)或 --resume(从当前页继续,自担漏检风险)。');
  process.exit(2);   // 第十二轮教训②:起点没约定,翻到篇末就永远 MISS
}
for (let i = 1; i <= budget; i += 1) {
  dump();
  const hit = texts().find((x) => x.includes(needle));
  if (hit) {
    // 码点安全:按码点截断预览,𤝻 这类非 BMP 字不会被劈成半个代理对
    console.log(`HIT page ${i} :: ${codePointSafePreview(hit)}`);
    process.exit(0);
  }
  swipeNext();
}

预检库里的码点扫描是个状态机,逐 UTF-16 码元配对高位代理与低位代理,配不上就拒收并报位置。第十轮踩的是对账管线的下标错位,这里是同一根源在工具层的防御:入口拒收孤立代理,出口按码点切字符串,两头都不给 UTF-16 下标空间留陷阱。

六、门禁与反向验证

工具转正不算完,规则要钉住。新增第 55 项门禁 verify-e2e-search-precheck.mjs,四组断言:

断言组条数钉什么
工具契约5设备循环前必须过预检;起点语义必须显式;预览码点安全;页位必须来自真分页器
历史对账9九条历史串逐条:8 条 single-node 且「预估 + 3 = 真机页」,1 条跨注拒跑且建议可复检
教训复现3犀渠 2 处拒跑 + force-first 放行留警告;预算 78 拒跑并报最低 151;孤立代理拒收
码点输出2𤝻 串放行;预览截断不劈代理对

首跑即 PASS。反向验证两组,都成立:把扉页扫描面清空(模拟旧工具的盲区),犀渠变成「唯一命中」,门禁当场报红 犀渠必须因多页命中拒跑 true !== false——这正是第十二轮白跑的复刻;把校准常数从 3 改成 2,九条对账全红 预估 128 + 2 ≠ 真机 131——页位表过期不再是没人知道的事。还原后复跑:单门禁 PASS、全量 55/55 PASS、HAP 构建 13.9 秒。

动作结果
verify-e2e-search-precheck 首跑PASS(21 条断言)
反向验证:清空扉页扫描面报红成立(唯一性盲区复刻第十二轮)
反向验证:校准常数 3→2报红成立(页位表过期立刻显形)
全量门禁55/55 PASS
HAP 构建BUILD SUCCESSFUL,13.9 s

门禁断言结构与反向验证两组报红

收个尾

如实记两条限制。其一,设备循环(hdc 抓取、翻页、回卷首)是旧工具已验证逻辑的移植加新契约,本轮没有在设备上重跑——模拟器磁盘映像被残留进程锁着,真机也不在场;但预检层离线可复跑,验收证据全部落在数据侧,这本来就是这次改造的用意。其二,附记页的异兽名在图鉴数据里,预检扫不到,检索串若是异兽名仍有被尾页卡片抢先命中的可能,工具注释里明写这条盲区。

下欠一笔新账:校准常数 3 是当前语料上的实测值,下次真机复验首跑时复核一遍,语料演进导致门禁报红时按门禁注释处置(重推页位、真机重校准、更新对账表)。工具从临时目录搬进 scripts 这件事拖了四轮——坑在工具层,账就该记在工具层,这轮把四条坑位和一条新发现一起钉住了。

证据清单

  • 预检库与工具:app-harmony/scripts/lib/e2e-search-precheck.mjs、app-harmony/scripts/e2e-page-search.mjs
  • 第 55 项门禁:app-harmony/scripts/verify-e2e-search-precheck.mjs(21 条断言,首跑 PASS)
  • 历史对账留证:docs/development/acceptance/2026-11-10-e2e-search-precheck/precheck-matrix.txt(九串页位对账)
  • CLI 预检留证:同目录 cli-quxi-refused.txt、cli-cross-note-refused.txt、cli-ok-p154.txt、cli-start-contract-refused.txt
  • 验收记录:同目录 ACCEPTANCE_e2e_search_precheck_2026-11-10.md(55/55 全绿、HAP 构建 13.9 秒、反向验证两组)
  • 十六轮真机复验原始留证:docs/development/acceptance/2026-10-16-shanhaijing-zhongshan-b6/(13 张截图,篇遇串页位由截图 07 留证)
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐