feat(harmony): P2a —— 收件箱按会话折叠 + 联系人页卡片视图(判据直接跑同一份逻辑)
按 pi 的结论落地 P2a 的前一半:**先补视图与折叠,再删平级「会话」tab**(tab 本轮保留)。 ## 判据怎么"点用户真正会点的那一层" 鸿蒙侧没有设备(`hdc list targets` 为空、模拟器在本机沙箱下起不来),"点一下"暂时 无法自动验。应对不是编个能过的新判据,而是把会点的那一层的内核抽成纯逻辑: `entry/src/main/ets/model/MailGrouping.ts`(无 UI 依赖),判据用 node 的 `--experimental-strip-types` **执行同一份代码**(`test/harmony-logic.test.mjs`,14 条), 断言的是行为而不是"源码里出现过某个字符串": - 折叠后组头是不是**最新一封**、组内是否时间倒序、组间排序、同一时刻用 `mail_id` 倒序兜底; - 时间解析失败**不能让顺序依赖入参**(WebUI 侧踩过的 NaN 比较坑); - 多账号合并下同名 `session_id` 不能被错并成一组;`session_id` 缺失时各自成组; - 预算档位与 WebUI `BudgetChip` 完全一致(剩 0 用尽 / ≤1 将尽 / 上限 0 不显示); - 视图切换与卡片上"最新一封是人还是 Agent"的判据。 页面那一层另用源码判据钉"确实调了这些函数",两层合起来覆盖「逻辑对」+「页面接上了」。 **变异验证 4 处全部判红**:去掉组内排序(2 条红)、预算阈值 `<=1` 改 `<1`、 分组键去掉账号前缀、页面不再区分单封组。 ## 收件箱折叠 - 组头取组内最新一封的别名与主题,带未读数徽标与「N 封」,点它展开/收起; - **单封不成组、平铺**(与 WebUI `isFlatGroup` 同结论:给孤立一封信套组头只是多一次点击); - 多账号是鸿蒙特有:分组键带账号前缀;`session_id` 缺失按 `mail:<id>` 各自成组。 ## 顺带修掉一个"看起来是总数、其实是未读数"的显示 `/me/mail/inbox` 的 `total` 是 **`CountUnread`(未读总数)**,不是总封数 (`server/internal/handler/me.go`)。鸿蒙底部原写「共 N 封」⇒ 同一屏出现 「共 7 封」和「未读 7」两行自相矛盾的字。改成:未读数用服务端 `total`(权威, 原来数这一页会少报);「共 N 封」→「已加载 N 封」;**这一页取满时如实提示 「已加载 50 封(本页上限 50,可能还有更多)」** —— 客户端没有可信总封数, 就不能把 50 封说成全部(pi 提醒的"别让只取 50 封伪装成只有这么多会话")。 WebUI 侧不读这个字段,故只影响鸿蒙。 ## 联系人页补卡片视图(撤 tab 的前置) - 右上角切列表/卡片,标题「联系人」/「工作列表」(与 WebUI 同词),切换规则在 `nextContactView()`; - 卡片对应 WebUI 的 `WorkCard`:Agent 名 + 工作目录 + 未读徽标、会话别名、 **主题当主角**、最新摘要 + 人/Agent 标记、`N 封 · 时间`、权限档位徽标、 **往返预算条**(同一档位判据)。 - 平级「会话」tab 暂留:撤 tab 按 pi 的顺序排在后面单独一步(撤早了预算/status/from_agent 没处看)。 ## 验证 - `hvigorw assembleHap` **BUILD SUCCESSFUL**(`.ts` 纯逻辑模块被 `.ets` 引用,实测可行)。 - `npm test` **退出码 0**:窄屏布局全通过、主题 30、背景 34、cross-client 8、 harmony-logic 14、packaging 3、vitest 258/258;新判据已接进 `npm test`。 - **视觉与点击仍未验**(无设备):展开手感、卡片间距、组头命中区没有任何自动判据 能代替人眼 —— 交付按"结构/逻辑已验证、观感未验"写,未写成已完成。
This commit is contained in:
@ -19,7 +19,7 @@
|
||||
"build:linux": "vite build && electron-builder --linux",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && node test/background.test.mjs && node test/cross-client-theme.test.mjs && node test/packaging.test.mjs && vitest run",
|
||||
"test": "node test/markdown-xss.test.mjs && node test/narrow-layout.test.mjs && node test/theme.test.mjs && node test/background.test.mjs && node test/cross-client-theme.test.mjs && node --experimental-strip-types --no-warnings --test test/harmony-logic.test.mjs && node test/packaging.test.mjs && vitest run",
|
||||
"test:narrow": "node test/manual/narrow-verify.mjs",
|
||||
"test:wide": "node test/manual/wide-regression.mjs",
|
||||
"test:components": "vitest run",
|
||||
|
||||
201
client/electron/test/harmony-logic.test.mjs
Normal file
201
client/electron/test/harmony-logic.test.mjs
Normal file
@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 鸿蒙侧的**可执行判据** —— 跑的是客户端真正会跑的那份逻辑。
|
||||
*
|
||||
* 为什么要有这个文件:
|
||||
*
|
||||
* 移交信里交代的头号纪律是「判据必须点用户真正会点的那一层」—— WebUI 侧就是因为
|
||||
* 只验了结构与样式、**一次都没点过**,漏掉了"点日历/联系人不翻页"的 bug 一路到线上。
|
||||
* 而鸿蒙侧现在**没有设备**(`hdc list targets` 为空、模拟器在本机沙箱下起不来),
|
||||
* 于是"点一下"这件事在鸿蒙上暂时无法自动验。
|
||||
*
|
||||
* 应对办法不是编个假判据,而是把**会点的那一层的内核**抽出来:
|
||||
* `client/harmony/entry/src/main/ets/model/MailGrouping.ts` 是纯逻辑、无 UI 依赖,
|
||||
* 本文件用 node 的 `--experimental-strip-types` **直接执行它**,断言的是行为
|
||||
* (折叠后组头是不是最新一封、单封是不是不成组、预算剩 1 个来回是什么档)。
|
||||
* 页面那一层再用源码判据钉住"确实调了这些函数"—— 两层合起来,
|
||||
* 「逻辑对」+「页面接上了」都有判据;剩下的"手感/观感"如实标注未验。
|
||||
*
|
||||
* 与 WebUI 的对应物:`src/lib/mailGroups.ts`(折叠 / isFlatGroup)、
|
||||
* `src/components/WorkCard.tsx` 的 `BudgetChip`(预算档位)、`ContactPanel.tsx`(视图切换)。
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = join(HERE, '..', '..', '..');
|
||||
const HARMONY_ETS = join(ROOT, 'client/harmony/entry/src/main/ets');
|
||||
const MODULE_TS = join(HARMONY_ETS, 'model/MailGrouping.ts');
|
||||
|
||||
/** 被测对象:鸿蒙客户端真正引用的那份逻辑(不是复制品) */
|
||||
const H = await import(pathToFileURL(MODULE_TS).href);
|
||||
|
||||
const page = readFileSync(join(HARMONY_ETS, 'pages/MainPage.ets'), 'utf8');
|
||||
const webGroups = readFileSync(join(ROOT, 'client/electron/src/lib/mailGroups.ts'), 'utf8');
|
||||
const webCard = readFileSync(join(ROOT, 'client/electron/src/components/WorkCard.tsx'), 'utf8');
|
||||
|
||||
/** 造一封邮件(只带折叠/渲染用得到的字段,字段名与 Models.ets 的 MailSummary 一致) */
|
||||
const mail = (over) => Object.assign(
|
||||
{
|
||||
mail_id: 'm-' + Math.random().toString(36).slice(2, 8),
|
||||
session_id: 's1',
|
||||
session_alias: '',
|
||||
from_name: 'pi',
|
||||
subject: '主题',
|
||||
body_preview: '预览',
|
||||
created_at: '2026-09-14T10:00:00Z',
|
||||
status: 'read',
|
||||
permission_mode: '',
|
||||
source_account_id: 'acct-1',
|
||||
source_account_name: '工作邮箱'
|
||||
},
|
||||
over
|
||||
);
|
||||
|
||||
// ───────────────────────── 收件箱折叠 ─────────────────────────
|
||||
|
||||
test('折叠:同一个会话的信合成一组,组头取**最新一封**', () => {
|
||||
const older = mail({ mail_id: 'm-a', session_id: 's1', created_at: '2026-09-14T09:00:00Z', subject: '旧主题', session_alias: '旧别名' });
|
||||
const newer = mail({ mail_id: 'm-b', session_id: 's1', created_at: '2026-09-14T11:00:00Z', subject: '新主题', session_alias: 'fix-x', status: 'unread' });
|
||||
const groups = H.groupMailsBySession([older, newer]);
|
||||
|
||||
assert.equal(groups.length, 1, '同一个 session_id 应该只有一组');
|
||||
assert.equal(groups[0].alias, 'fix-x', '组头别名应取最新一封');
|
||||
assert.equal(groups[0].subject, '新主题', '组头主题应取最新一封(会话主题会随任务推进被改写)');
|
||||
assert.equal(groups[0].latest.mail_id, 'm-b');
|
||||
assert.equal(groups[0].unreadCount, 1, '组内未读数');
|
||||
assert.deepEqual(groups[0].mails.map(m => m.mail_id), ['m-b', 'm-a'], '组内按时间倒序');
|
||||
});
|
||||
|
||||
test('★ 判据自检:把组头当成"第一封"而不是"最新一封"必须判红', () => {
|
||||
// 故意按"旧 → 新"传入:实现里少了 sort 的话,mails[0] 就是旧的,组头会写错
|
||||
const older = mail({ mail_id: 'm-a', session_id: 's1', created_at: '2026-09-14T09:00:00Z', subject: '旧主题' });
|
||||
const newer = mail({ mail_id: 'm-b', session_id: 's1', created_at: '2026-09-14T11:00:00Z', subject: '新主题' });
|
||||
const groups = H.groupMailsBySession([older, newer]);
|
||||
assert.notEqual(groups[0].subject, '旧主题', '组头取到了旧的那封 —— 折叠没有排序');
|
||||
});
|
||||
|
||||
test('单封的组不算「组」(与 WebUI isFlatGroup 同一结论)', () => {
|
||||
const one = H.groupMailsBySession([mail({ session_id: 's1' })]);
|
||||
const two = H.groupMailsBySession([mail({ session_id: 's1' }), mail({ session_id: 's1', created_at: '2026-09-14T11:00:00Z' })]);
|
||||
assert.equal(H.isFlatGroup(one[0]), true);
|
||||
assert.equal(H.isFlatGroup(two[0]), false);
|
||||
// WebUI 侧同一条规则仍在(哪边改了口径,这条会红)
|
||||
assert.match(webGroups, /export function isFlatGroup\(g: MailGroup\): boolean \{\s*return g\.mails\.length === 1;/, 'WebUI 的 isFlatGroup 口径变了');
|
||||
});
|
||||
|
||||
test('组间按最新一封倒序;时间相同时用 mail_id 倒序兜底', () => {
|
||||
const a = mail({ mail_id: 'm-1', session_id: 'sa', created_at: '2026-09-14T09:00:00Z' });
|
||||
const b = mail({ mail_id: 'm-2', session_id: 'sb', created_at: '2026-09-14T12:00:00Z' });
|
||||
const groups = H.groupMailsBySession([a, b]);
|
||||
assert.deepEqual(groups.map(g => g.session_id), ['sb', 'sa']);
|
||||
|
||||
// 同一时刻:mail_id 倒序(与后端 ORDER BY created_at DESC, mail_id DESC 一致)
|
||||
const t = '2026-09-14T10:00:00Z';
|
||||
const x = mail({ mail_id: 'm-aaa', session_id: 'sx', created_at: t });
|
||||
const y = mail({ mail_id: 'm-zzz', session_id: 'sy', created_at: t });
|
||||
assert.deepEqual(H.groupMailsBySession([x, y]).map(g => g.session_id), ['sy', 'sx']);
|
||||
});
|
||||
|
||||
test('时间解析失败不抛错、也不把顺序交给入参(NaN 参与比较恒为 false 的坑)', () => {
|
||||
const bad = mail({ mail_id: 'm-bad', session_id: 'sbad', created_at: '不是时间' });
|
||||
const good = mail({ mail_id: 'm-ok', session_id: 'sok', created_at: '2026-09-14T10:00:00Z' });
|
||||
const forward = H.groupMailsBySession([bad, good]).map(g => g.session_id);
|
||||
const backward = H.groupMailsBySession([good, bad]).map(g => g.session_id);
|
||||
assert.deepEqual(forward, backward, '入参顺序换了,结果就变 —— 排序依赖了 NaN 比较');
|
||||
assert.deepEqual(forward, ['sok', 'sbad']);
|
||||
});
|
||||
|
||||
test('鸿蒙特有:多账号收件箱里,同一 session_id 出现在两个账号是两件事', () => {
|
||||
const fromA = mail({ mail_id: 'm-a', session_id: 'shared', source_account_id: 'acct-1' });
|
||||
const fromB = mail({ mail_id: 'm-b', session_id: 'shared', source_account_id: 'acct-2', created_at: '2026-09-14T11:00:00Z' });
|
||||
const groups = H.groupMailsBySession([fromA, fromB]);
|
||||
assert.equal(groups.length, 2, '两个账号的同名会话被折叠成了一组 —— 键里少了账号');
|
||||
});
|
||||
|
||||
test('session_id 缺失的信各自成组(一条脏数据不该让整栏空白)', () => {
|
||||
const a = mail({ mail_id: 'm-1', session_id: '' });
|
||||
const b = mail({ mail_id: 'm-2', session_id: '' });
|
||||
const groups = H.groupMailsBySession([a, b]);
|
||||
assert.equal(groups.length, 2);
|
||||
assert.equal(H.isFlatGroup(groups[0]), true);
|
||||
});
|
||||
|
||||
// ───────────────────────── 未读数与"这一页可能不全" ─────────────────────────
|
||||
|
||||
test('未读数用服务端 total 相加(它是 CountUnread,权威),负数/0 忽略', () => {
|
||||
assert.equal(H.sumUnreadTotals([3, 4]), 7);
|
||||
assert.equal(H.sumUnreadTotals([0, -1, 2]), 2);
|
||||
assert.equal(H.sumUnreadTotals([]), 0);
|
||||
assert.match(page, /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(page, /partialLoadNotice\(/, '页面要用这条提示');
|
||||
});
|
||||
|
||||
test('★ 界面不再把"服务端未读数"当成"总封数"显示', () => {
|
||||
// 原先底部写的是「共 N 封」,而那个 N 是 /me/mail/inbox 的 total(= CountUnread),
|
||||
// 于是同一屏上会出现「共 7 封」和「未读 7」这种自相矛盾的两行字。
|
||||
assert.ok(
|
||||
!/共 ' \+ this\.total \+ ' 封/.test(page),
|
||||
'页面里还有「共 N 封」—— 服务端 total 是未读数,不是总封数'
|
||||
);
|
||||
assert.match(page, /已加载 ' \+ this\.loaded \+ ' 封/, '应如实说"已加载了多少封"');
|
||||
});
|
||||
|
||||
// ───────────────────────── 预算(与 WebUI BudgetChip 同判据) ─────────────────────────
|
||||
|
||||
test('往返预算档位与 WebUI 的 BudgetChip 完全一致', () => {
|
||||
// WebUI 的判据(WorkCard.tsx):max<=0 不显示;剩 0 = 用尽;剩 ≤1 = 将尽;其余普通
|
||||
assert.match(webCard, /if \(!max \|\| max <= 0\) return null;/, 'WebUI 的"不限不显示"口径变了');
|
||||
assert.match(webCard, /remaining === 0/, 'WebUI 的"用尽"判据变了');
|
||||
assert.match(webCard, /remaining <= 1/, 'WebUI 的"将尽"判据变了');
|
||||
|
||||
// 鸿蒙侧同一批输入必须给出同样的档位
|
||||
assert.equal(H.budgetState(0, 0), 'none', '上限 0 = 不限,不显示');
|
||||
assert.equal(H.budgetState(5, 5), 'spent', '剩 0 = 用尽');
|
||||
assert.equal(H.budgetState(5, 4), 'warn', '剩 1 = 将尽(快跑满的任务要人介入)');
|
||||
assert.equal(H.budgetState(5, 3), 'ok');
|
||||
assert.equal(H.budgetState(5, 9), 'spent', '用超了也是用尽,不能算成还剩负数');
|
||||
assert.equal(H.budgetLabel(5, 4), '1/5');
|
||||
assert.equal(H.budgetLabel(0, 0), '', '不限时徽标文字是空串(页面据此不渲染)');
|
||||
assert.match(page, /budgetLabel\(c\.max_rounds, c\.used_rounds\)/, '卡片上要显示预算');
|
||||
assert.match(page, /budgetState\(c\.max_rounds, c\.used_rounds\)/, '卡片上要用同一档位判据');
|
||||
});
|
||||
|
||||
// ───────────────────────── 联系人页两种视图(为撤掉平级「会话」tab 做准备) ─────────────────────────
|
||||
|
||||
test('联系人页的列表/卡片切换:点一次换一次,标题用 WebUI 那套词', () => {
|
||||
assert.equal(H.nextContactView('list'), 'card');
|
||||
assert.equal(H.nextContactView('card'), 'list');
|
||||
assert.equal(H.contactViewTitle('card'), '工作列表', '卡片视图的标题应与 WebUI 一致');
|
||||
assert.equal(H.contactViewTitle('list'), '联系人');
|
||||
|
||||
assert.match(page, /this\.contactView = nextContactView\(this\.contactView\)/, '切换按钮要走这条判据');
|
||||
assert.match(page, /contactViewTitle\(this\.contactView\)/, '标题要走这条判据');
|
||||
});
|
||||
|
||||
test('卡片上"最新一封是谁发的":人 vs Agent(决定人要不要接手)', () => {
|
||||
assert.equal(H.lastFromIsHuman('pi', 'jianf'), true);
|
||||
assert.equal(H.lastFromIsHuman('pi', 'pi'), false);
|
||||
assert.match(page, /lastFromIsHuman\(c\.agent_name, c\.last_from\)/, '卡片要用这条判据选图标');
|
||||
// WebUI 侧同一判据仍在
|
||||
assert.match(webCard, /const fromHuman = c\.last_from !== c\.agent_name;/, 'WebUI 的 fromHuman 口径变了');
|
||||
});
|
||||
|
||||
// ───────────────────────── 两层的接合:页面确实调了被测逻辑 ─────────────────────────
|
||||
|
||||
test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用")', () => {
|
||||
assert.match(page, /import \{[\s\S]*groupMailsBySession[\s\S]*\} from '\.\.\/model\/MailGrouping'/, '页面要 import 折叠逻辑');
|
||||
assert.match(page, /this\.groups = groupMailsBySession\(mergedMails\)/, '加载后要折叠');
|
||||
assert.match(page, /if \(isFlatGroup\(g\)\)/, '单封的组要平铺渲染(这条就是"点开会多一次点击"的那个分支)');
|
||||
assert.match(page, /this\.isExpanded\(g\.key\)/, '多封的组要按展开状态渲染');
|
||||
assert.match(page, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)');
|
||||
assert.match(page, /this\.WorkCard\(c\)/, '卡片视图要真的渲染出来');
|
||||
});
|
||||
Reference in New Issue
Block a user