diff --git a/client/electron/test/harmony-2in1.test.mjs b/client/electron/test/harmony-2in1.test.mjs index 93974f5..b2da8fd 100644 --- a/client/electron/test/harmony-2in1.test.mjs +++ b/client/electron/test/harmony-2in1.test.mjs @@ -670,15 +670,97 @@ test('2in1|顶栏摘要"零"时也要有文案(否则整块消失)', () => * 实测:那封邮件已读 ⇒ 三项全 0 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**, * 顶栏右上什么都没有。 * - * 而"没有未读"恰恰是**常态**(收件箱清干净了)。那时该说"暂无待办", - * 而不是整块消失让人以为坏了。 + * 而"没有未读"恰恰是**常态**(收件箱清干净了)。 * - * 改坏会红:把 `return '暂无待办'` 改回 `return ''`。 + * ★★ 文案改过两轮,判据跟着改(用户裁定): + * 第 0 版 空串 → 整块消失(顶栏右侧空着、与三键失衡) + * 第 1 版 「暂无待办」→ 补上了"零也是信息",但这句话**信息量为 0 且会误导**: + * 它读起来像"没事可做",实际只表示"三个计数是 0"。 + * 第 2 版 「AgentMail」(品牌名)—— 不带任何判断。 + * + * ⇒ 判据钉**两条**:① 兜底必须存在(别退回空串让整块消失); + * ② 兜底文案**不含判断词**("暂无/没有/无"开头都是在替用户下结论)。 + * + * 改坏会红:把 `TOPBAR_FALLBACK` 改回 `''`,或改回 `'暂无待办'`。 */ - const MAIN = code(join(ETS, 'pages/MainPage.ets')); - assert.match(MAIN, /暂无待办/, - '摘要为零时要有兜底文案 —— "零"本身也是信息,不该让整块消失'); -}); + assert.match(MAIN, /const TOPBAR_FALLBACK: string = '[^']+';/, + '兜底文案要存在(退回空串会让整块消失、顶栏右侧空着)'); + const fallback = MAIN.match(/const TOPBAR_FALLBACK: string = '([^']+)';/); + assert.ok(fallback, '要能取到兜底文案'); + assert.doesNotMatch(fallback[1], /^暂无|^没有|^无/, + '兜底文案不许下判断("暂无X"是在替用户下结论,而触发条件只是计数为零)'); + + /* + * ③ 兜底**不占轮播位** —— 它只做"唯一候选"。 + * + * 实测:四张连拍(3s 一张)得到 `一言 → 兜底 → 兜底 → 一言`, + * 轮播位有一半被废话吃掉(那时一言只有 2 条)。 + * ⇒ 摘要有真计数才配占位;兜底只在**三类都空**时才作为唯一候选出现。 + * + * 结构上是两半,都要钉住(少一半就退回旧行为): + * a) 摘要入口有 `summaryIsReal` 守卫(假摘要不进数组) + * b) 数组为空时才 push 兜底(不是无条件 push) + * + * 改坏会红:删掉任一半。 + */ + assert.match(MAIN, /const summaryIsReal: boolean = this\.navUnread > 0 \|\| this\.navPending > 0 \|\| this\.navContacts > 0;/, + '摘要要有"是否为真计数"的判断'); + assert.match(MAIN, /if \(summaryIsReal\) \{\s*\n\s*out\.push\(summary\);\s*\n\s*\}/, + '假摘要(兜底)不许进候选数组 —— 否则它会被轮播到、占掉一半时长'); + assert.match(MAIN, /if \(out\.length === 0\) \{\s*\n\s*out\.push\(TOPBAR_FALLBACK\);\s*\n\s*\}/, + '兜底只在数组为空时作为唯一候选进入'); + + /* + * ④ 一言**只显示句子**,不带出处(用户裁定)。 + * + * 原形状 `quote + ' —— ' + source` 的问题: + * 出处占近 1/3 宽度、把句子本身挤到省略号; + * 且 hitokoto 的出处格式杂(动漫角色/诗词/网名),窄带上排起来脏。 + * ⇒ 与签名对齐:两者都是"当前状态的一句话",都不带出处。 + * + * 判据钉**两半**:① 拼装里不含 `——` + source; + * ② `topQuoteSource` 这个状态整个不存在了(留着就是只写不读的死字段)。 + * + * 改坏会红:把拼接改回去,或把 `topQuoteSource` 状态加回来。 + */ + /* + * ★ 这里我第一版写漏了,记下来: + * 正则写成 `/this\.topQuote \+ ' —+ ' \+ this\.topQuoteSource/` —— + * **只匹配单引号**。而变异时我把拼接写成双引号 `" —— "`, + * 正则没命中 ⇒ 判据**假绿**(变异验证的意义就在这:它当场抓到了)。 + * + * ⇒ 改成**正向断言**:必须存在"把 topQuote 原样推进数组"这一句。 + * 正向比反向窄,且不依赖引号风格。 + */ + assert.match(MAIN, /@State topQuotes: string\[\] = \[\];/, + '一言要存**整批**(单个字符串装不下一批,见下条)'); + assert.match(MAIN, /this\.topQuotes\.push\(q\)|out\.push\(q\);/, + '一言要**逐句**进候选数组'); + assert.doesNotMatch(MAIN, /this\.topQuote\b(?!s)/, + '不许再有单句形态的 topQuote(它是"只取第一句"那个 bug 的载体)'); + assert.doesNotMatch(MAIN, /this\.topQuoteSource/, + '出处不许出现在代码里(只写不读的死字段也要删)'); + + /* + * ⑤ **整批都要存下来** —— 只取第一句会让轮播静默失效。 + * + * 这是本轮最值钱的一条判据,因为那个 bug **不报错、不崩溃**: + * `this.topQuote = content.quotes[0].text` ⇒ 本地只有 1 句 ⇒ + * `startTopbarRotation` 的 `texts.length <= 1` 守卫直接 return ⇒ 不轮播。 + * 我连拍 6 张(18s)全是同一句才发现。 + * + * ★ 更坏的是它是**假象**:先前看着"在转",是因为兜底文案占了位 + * (一言 ↔ 暂无待办 交替);等兜底改成不占轮播位,转的其实是兜底。 + * ⇒ 判据必须钉"服务端给的那一批**全都进**本地状态"。 + * + * 改坏会红:把赋值改回 `quotes[0]`。 + */ + assert.match(MAIN, /for \(let i = 0; i < content\.quotes\.length; i\+\+\)/, + '要遍历服务端返回的整批(只取 [0] 会让轮播静默失效)'); + assert.match(MAIN, /this\.topQuotes = qs;/, + '整批赋给本地状态'); + assert.doesNotMatch(MAIN, /@State topQuoteSource/, + '出处状态要删干净,不留只写不读的死字段');}); test('2in1|一言与签名要在客户端缓存(账号级),离线也能轮播', () => { /* diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index b506569..a967136 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -169,6 +169,13 @@ const TOPBAR_FADE_MS: number = 260; */ /* 文案与窗口左缘的距离(vp)—— 与内容区起始留白同档 */ const TOPBAR_STRIP_LEFT: number = 16; +/* + * 兜底文案(没有任何真内容时显示)。 + * + * ★ 用户裁定用品牌名,不用「暂无待办」—— 理由见 `topbarSummary()` 的注释: + * "暂无X"是在替用户下结论,而触发条件只是"三个计数为零",两者不等价。 + */ +const TOPBAR_FALLBACK: string = 'AgentMail'; /* * 顶栏文案字号(vp)。 * @@ -2481,8 +2488,25 @@ struct MainPage { * · 一言 —— 服务端句库(本地有缓存,离线也照转) * · 签名 —— 用户个人签名(没设过就不进轮播) */ - @State topQuote: string = ''; - @State topQuoteSource: string = ''; + /* + * ★★ 2026-09-25:**整批存下来,本地在批内轮播**(原来是 `topQuote: string` 单句)。 + * + * ── 原来的错 ── + * 我写成 `this.topQuote = content.quotes[0].text`,注释还振振有词: + * 「批次的意义是'少请求',不是'一次全显示';一次显示多句反而乱」 + * —— 那句话本身没错,但我**只取第一句** ⇒ 本地永远只有 1 句 ⇒ + * `startTopbarRotation` 里 `texts.length <= 1` 的守卫直接 `return` + * ⇒ **轮播根本不转**(6 张连拍 18 秒全是同一句)。 + * + * 之前看着"在转",靠的是兜底文案占了位(一言 ↔ 暂无待办 交替)—— + * 那是**假象**。等我把兜底改成不占轮播位,真相立刻暴露。 + * + * ── 正确做法 ── + * 服务端一批给 10 句(每次顺序还随机)⇒ 整批存进本地缓存, + * 让本地轮播在**批内**逐句换。这正好落实用户最初那句 + * 「app 本地缓存一部分」——缓存的是**一批**,不是一个。 + */ + @State topQuotes: string[] = []; @State topSignature: string = ''; /** 当前显示到第几个(对"可用文案列表"取模) */ @State topIndex: number = 0; @@ -2599,19 +2623,57 @@ struct MainPage { */ private topbarTexts(): string[] { const out: string[] = []; - /* 摘要:当前栏的统计。三个栏各自的口径见 loadBadges/summarize 的注释 */ + /* + * 摘要:当前栏的统计。三个栏各自的口径见 loadBadges/summarize 的注释。 + * + * ★★ 2026-09-25:**兜底不占轮播位**。 + * 实测四张连拍(3s 一张):`一言 → 摘要 → 摘要 → 一言` —— + * 轮播位有一半被兜底文案吃掉,而那时一言只有 2 条、 + * 等于用户看到的内容里**一半是废话**。 + * + * 规则:摘要**有真计数**(unread/pending/contacts 至少一个 > 0)才占位; + * 落到兜底(品牌名)说明没有任何待办 —— 那就不必用一整个轮播位去说它。 + * ⇒ 兜底只在**其它文本一条都没有**时才作为唯一的候选显示(见下面 return 前那段)。 + */ const summary: string = this.topbarSummary(); - if (summary.length > 0) { + const summaryIsReal: boolean = this.navUnread > 0 || this.navPending > 0 || this.navContacts > 0; + if (summaryIsReal) { out.push(summary); } - if (this.topQuote.length > 0) { - out.push(this.topQuoteSource.length > 0 - ? this.topQuote + ' —— ' + this.topQuoteSource - : this.topQuote); + /* + * ★★ 2026-09-25:一言**只显示句子本身,不带出处**(用户裁定)。 + * + * ── 原来长这样 ── + * `this.topQuote + ' —— ' + this.topQuoteSource` + * ⇒「青山相待,白云相爱,梦不到紫罗袍共黄金带。 —— 宋方壶」 + * + * ── 为什么不带出处 ── + * ① 顶栏是**一句话的位置**,不是引文区。出处(`—— 宋方壶`)占了 + * 近三分之一宽度,把句子本身挤到省略号。 + * ② 这些句子来自 hitokoto,出处杂(动漫角色、诗词、网络段子), + * 格式也不统一(有的带书名号、有的就是个人名)—— + * 在一条 14vp 的窄带上排版起来很脏。 + * ③ 与签名对齐:签名也没有"出处"。两者都是"当前状态的一句话"。 + */ + for (let i = 0; i < this.topQuotes.length; i++) { + const q: string = this.topQuotes[i]; + if (q.length > 0) { + out.push(q); + } } if (this.topSignature.length > 0) { out.push(this.topSignature); } + /* + * 兜底的**唯一**用途:上面三类**一条都没有**时别让整块消失 + * (否则顶栏右侧空着,与三键之间失衡 —— 用户最初就是为这个才要这块)。 + * + * ⇒ 它进的是"唯一候选",不是"轮播位之一": + * 只要真内容存在(哪怕只有一条一言),兜底就不会出现。 + */ + if (out.length === 0) { + out.push(TOPBAR_FALLBACK); + } return out; } @@ -2644,16 +2706,23 @@ struct MainPage { parts.push(this.navContacts + ' 会话'); } /* - * ★★ 2026-09-25 加兜底(实测撞出来的): - * 三项都是 0 时返回空串 ⇒ `topbarTexts()` 成空数组 ⇒ 整块**不渲染**。 - * 而"没有未读"恰恰是**常态**(收件箱清干净了)—— - * 那时顶栏该说"都清完了",而不是整块消失让人以为坏了。 + * ★★ 2026-09-25 兜底 = **品牌名**(用户裁定:「AgentMail」)。 * - * ★ 这与我先前那版"三项都是 0 就不显示"的判断相反 —— - * 那句话是我按"有信息才显示"想当然写的,没考虑"零"本身也是信息。 + * ── 这里改过两轮,记下为什么 ── + * 第 0 版:三项都是 0 ⇒ 返回空串 ⇒ `topbarTexts()` 成空数组 ⇒ **整块不渲染**。 + * "没有未读"是常态,那时顶栏不该整块消失(看着像坏了)。 + * 第 1 版:「暂无待办」—— 先把"零也是信息"补上了,但这句话 + * **信息量是 0 且会误导**:它读起来像"这个 App 里没事可做", + * 而实际上它只表示"这三个计数当前都是 0"。 + * 第 2 版(本版):「AgentMail」。 + * + * ★ 判据:兜底文案要**不含判断**。 + * 任何"暂无/没有/无"开头的句子都是在替用户下结论, + * 而这句话的触发条件只是"三个计数为零" —— 结论与事实不等价。 + * 品牌名没有这个毛病:它只是标识,且与右边三键在视觉上相称。 */ if (parts.length === 0) { - return '暂无待办'; + return TOPBAR_FALLBACK; } return parts.join(' · '); } @@ -2741,9 +2810,19 @@ struct MainPage { * 而我们这里已经有本地定时轮播 —— 一次显示多句反而乱。 * 批次的存在意义是"少请求",不是"一次全显示"。 */ - if (content.quotes.length > 0) { - this.topQuote = content.quotes[0].text; - this.topQuoteSource = content.quotes[0].source; + /* + * 整批存(服务端一批给 10 句),本地在批内轮播。 + * 只取句子、不取出处 —— 理由见 `topbarTexts()`。 + */ + const qs: string[] = []; + for (let i = 0; i < content.quotes.length; i++) { + const t: string = content.quotes[i].text; + if (t.length > 0) { + qs.push(t); + } + } + if (qs.length > 0) { + this.topQuotes = qs; } this.topSignature = content.signature; }