判据: 三条判据锚在"实现位置"上,A 步搬家后假红 —— 改成锚不变量

用户裁定做 A(harmony 补 store 层)之后,`harmony-logic.test.mjs` 红了 4 条。
**一条都不是 bug**:它们读 `pageCode`(`MainPage.ets`)找那些调用,
而调用已经搬进 `common/MailStore.ets`。

这正是本仓反复出现的形状:**判据锚在实现位置上,而不是它声称的东西上**。
一处实现搬家(哪怕搬得更对)就假红 —— 而假红比漏报更坏,
它会让人去改本来对的代码。

★ 修法:引入 `compositionCode`(页面 + store 合起来看),按**归属**拆断言
  · **取数/组装**(谁调 `sumUnreadTotals` / `partialLoadNotice` /
    `groupMailsBySession`)→ 读 `compositionCode`
  · **渲染**(用户看到什么:单封平铺、多封按展开态、组头可点、卡片视图)→ 仍钉 `pageCode`
    (那是页面该管的事,不该被搬走)
  不变量是"这条纯逻辑真的被用上了"——**用它的人搬去哪一层不该让判据红**。

★ 顺带修掉一条**自己骗自己**的断言(变异测试抓出来的)
  「先分家再折叠」原本写成 `splitAt < groupAt` 两个 `indexOf` 比大小,
  并断言两个都 `>= 0`。我把顺序反过来做变异 —— **它没红**:
  因为反过来之后第二个字面量变了、`indexOf` 返回 -1,红的是"两个都 >=0"
  那条,而不是顺序那条。也就是说:**顺序这条判据从来没在判顺序**。
  改成直接断言**数据流形状** `groupMailsBySession(split.normal)` ——
  它同时表达"折叠的是筛过的那批"与"必须先分家"。再变异(换成 `mergedMails`)
  ⇒ fail=2,这次真的咬住了。

★ 判据 14 也从一条拆成两条(取数侧 / 渲染侧),名字改成
  「折叠逻辑真正接上了(不是"逻辑写好了没人用")—— 取数侧 + 渲染侧都要在」。

harmony-logic 32/32 绿。注册数不变(拆一条加一条,净持平 32)。
This commit is contained in:
2026-09-20 11:34:44 +08:00
parent 97037c0424
commit f604badf86

View File

@ -45,6 +45,22 @@ const page = code(join(HARMONY_ETS, 'pages/MainPage.ets'));
* 已经用过一次(遮罩那段注释里引用了旧值),这里统一成常态。
*/
const pageCode = page.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
/*
* ★★ 2026-09-20:再加一份「取数/组装那层」的源码。
*
* 用户裁定做 A(harmony 补 store 层)之后,**收件箱的取数与组装
* (`sumUnreadTotals` / `partialLoadNotice` / `splitByPermission` /
* `groupMailsBySession` 的调用)从 `MainPage.ets` 搬进了
* `common/MailStore.ets`**。
*
* 于是本文件里几条"页面要用这个纯逻辑"的判据**锚错了文件** —— 它们读
* `pageCode`,而那些调用现在在 store 里。这不是判据发现了 bug,是判据
* 锚在了**实现位置**而不是**它声称的不变量**("这条纯逻辑真的被用上了")。
*
* 所以这里引入 `compositionCode`:**两处合起来**看。
* 不变量是"这个纯函数在数据流里被用了",用它的人搬去哪一层不该让判据红。
*/
const compositionCode = pageCode + '\n' + code(join(HARMONY_ETS, 'common/MailStore.ets'));
const webGroups = code(join(ROOT, 'client/electron/src/lib/mailGroups.ts'));
const webCard = code(join(ROOT, 'client/electron/src/components/WorkCard.tsx'));
@ -144,14 +160,14 @@ test('未读数用服务端 total 相加(它是 CountUnread,权威),负
assert.equal(H.sumUnreadTotals([3, 4]), 7);
assert.equal(H.sumUnreadTotals([0, -1, 2]), 2);
assert.equal(H.sumUnreadTotals([]), 0);
assert.match(pageCode, /sumUnreadTotals\(/, '页面要用服务端未读数,而不是数这一页');
assert.match(compositionCode, /sumUnreadTotals\(/, '数据流里要用服务端未读数,而不是数这一页');
});
test('只有"取满了这一页"才提示可能还有更多(服务端 total 是未读数,不是总封数)', () => {
assert.equal(H.partialLoadNotice(12, 50), '', '没取满就别吓人');
assert.equal(H.partialLoadNotice(50, 50), '已加载 50 封(本页上限 50,可能还有更多)');
assert.equal(H.partialLoadNotice(0, 0), '', 'limit 不合法时不提示');
assert.match(pageCode, /partialLoadNotice\(/, '页面要用这条提示');
assert.match(compositionCode, /partialLoadNotice\(/, '数据流里要用这条提示');
});
test('★ 界面不再把"服务端未读数"当成"总封数"显示', () => {
@ -212,11 +228,32 @@ test('卡片上"最新一封是谁发的":人 vs Agent(决定人要不要接
// ───────────────────────── 两层的接合:页面确实调了被测逻辑 ─────────────────────────
test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用")', () => {
assert.match(pageCode, /import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/, '页面要 import 折叠逻辑');
test('折叠逻辑真正接上了(不是"逻辑写好了没人用")—— 取数侧 + 渲染侧都要在', () => {
/*
* ★★ 2026-09-20 拆成两半(用户裁定做 A:补 store 层之后本判据红了)。
*
* 它原来把两件事混在一条里,都断言在 `pageCode`(`MainPage.ets`)上:
* · **取数侧**:"加载后要折叠"—— `this.groups = groupMailsBySession(inboxMails)`
* · **渲染侧**:"单封平铺 / 多封按展开态 / 组头可点"
* 而 A 步把取数侧搬进了 `common/MailStore.ets`。于是判据红的是**取数侧那一条**,
* 而它声称的不变量("折叠逻辑真的被用上了")其实**仍然成立**。
*
* 这正是本仓反复出现的形状:**判据锚在实现位置上,而不是它声称的东西上**。
* 一处实现搬家(哪怕搬得更对)就会假红 —— 而假红比漏报更坏,
* 它会让人去改本来对的代码。
*
* 拆法按**归属**分:
* · 取数/组装 → `compositionCode`(页面 + store 合起来看)
* · 渲染 → 仍钉 `pageCode`(那是页面该管的事,不该被搬走)
*/
assert.match(compositionCode,
/import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/,
'数据流那一层要 import 折叠逻辑');
// 加载后要折叠。变量是**筛过权限邮件之后**的那一批(权限邮件归授权栏,
// 收件箱里不该出现它们 —— 见"权限邮件不进收件箱"那条)。
assert.match(pageCode, /this\.groups = groupMailsBySession\(inboxMails\)/, '加载后要折叠(筛过权限邮件的那批)');
assert.match(compositionCode, /groupMailsBySession\(split\.normal\)/,
'加载后要折叠(筛过权限邮件的那批)—— A 步之后这行在 MailStore 里');
/* 渲染侧:下面三条仍必须落在页面上 —— 它们描述的是"用户看到什么" */
assert.match(pageCode, /if \(isFlatGroup\(g\)\)/, '单封的组要平铺渲染(这条就是"点开会多一次点击"的那个分支)');
assert.match(pageCode, /this\.isExpanded\(g\.key\)/, '多封的组要按展开状态渲染');
assert.match(pageCode, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)');
@ -574,10 +611,38 @@ test('通信页把三栏真的接上了:内部页签 + 徽标 + 悬浮加号 +
// 收件箱那一栏必须**先分家再折叠**(否则权限邮件会混进收件箱,正是这条要防的)
// 注意切到那一行**之后**再截断:原来截到 indexOf 处,正好把要断言的那一行切掉,
// 判据于是报"再折叠筛过的那些"失败 —— 判据自己的切片边界错了(变异测试式的自省)
const gStart = sendCode.indexOf('this.groups = groupMailsBySession');
const inboxLoad = sendCode.slice(sendCode.indexOf('async loadData'), gStart + 60);
assert.match(inboxLoad, /splitByPermission\(mergedMails\)/, '收件箱要先分家');
assert.match(inboxLoad, /groupMailsBySession\(inboxMails\)/, '再折叠筛过的那些');
/*
* ★★ 2026-09-20:这两条改读 store(A 步之后取数/組装在 `MailStore.ets`)。
*
* 原来按 `sendCode`(MainPage)从 `async loadData` 切到
* `this.groups = groupMailsBySession` 之间取片段 —— 那段现在在 store 里,
* 页面里的 `loadData` 只剩三行调用。判据于是假红(它声称的"先分家再折叠"
* 仍然成立,只是实现搬了家)。
*
* 仍按**顺序**判(先 split 再 group)——那是这条不变量真正的形状,
* 与在哪个文件里无关。
*/
const storeCode = code(join(HARMONY_ETS, 'common/MailStore.ets'));
/*
* ★★ 只查"两行都在"是不够的 —— 我第一版就是这么写的,而**变异测试没咬住**:
* 把顺序反过来(先 `groupMailsBySession(全部)` 再 `splitByPermission`)之后
* 两行仍然都在、`indexOf` 也仍然一大一小(因为第二行的字面量变了,
* 而我当时匹配的是 `groupMailsBySession(split.normal)` ⇒ 它直接找不到,
* 却因为我把断言写成"两个都 >=0 且 splitAt < groupAt",反过来之后
* `groupAt` 变成 -1 ⇒ 那次是**另一个断言**红的,不是顺序这条)。
*
* 真正要钉的是:**`splitByPermission` 的结果被喂给 `groupMailsBySession`**。
* 那就直接断言那个**数据流**形状 —— 而不是两行的相对位置。
*/
assert.match(storeCode, /snap\.groups = groupMailsBySession\(split\.normal\)/,
'收件箱那一栏要折叠**分家之后**的普通邮件(`split.normal`)—— ' +
'喂 `mergedMails`(含权限请求)就是把权限邮件混进收件箱,这条判据防的正是它');
assert.match(storeCode, /const split: MailSplit = splitByPermission\(mergedMails\)/,
'分家要在合并后的全量上做一次(`mergedMails`),而不是在某一账号的局部');
const splitAt = storeCode.indexOf('splitByPermission(mergedMails)');
const groupAt = storeCode.indexOf('groupMailsBySession(split.normal)');
assert.ok(splitAt >= 0 && groupAt >= 0 && splitAt < groupAt,
'★ 先分家、后折叠(顺序反了会先折叠进权限邮件,再想筛也晚了)');
});
test('发件箱与授权栏走的是与 WebUI 相同的接口(路径、字段、备注都要对)', () => {