diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index bfcaa69..2a07cde 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -671,3 +671,61 @@ test('★ 行为:手势触发的翻页与按钮触发的翻页走同一个函 assert.match(page, /\.onClick\(\(\)\s*=>\s*this\.shiftMonth\(-1\)\)/, '上一页按钮'); assert.match(page, /\.onClick\(\(\)\s*=>\s*this\.shiftMonth\(1\)\)/, '下一页按钮'); }); + +/* ───────── 会话改名建议:契约形状与两端行为一致(纯逻辑层) ───────── */ + +test('★ 改名建议:字段名与两端契约一致,接受/驳回走对端点(一个走别名、一个走专用端点)', () => { + /* + * 2026-09-19 审计发现:鸿蒙详情页**没有**改名建议条(WebUI 有, + * `MailView.tsx:324` 的 `RenameProposalBar`)。那件事对**寻址稳定性**很重要 + * (WebUI 注释原话:「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」)。 + * + * 这一批先把**接口层**做完(`api/SessionApi.ets` + `model/SessionRename.ets`), + * 这条判据钉住它与服务端的契约 —— 形状错了 UI 接上也读不出东西。 + * + * 服务端契约(`server/internal/handler/sessions.go:222`): + * GET → {"proposal": {"alias": "...", "reason": "..."}} 或 {"proposal": null} + * POST /rename-proposal/dismiss ← 驳回 + * PUT /sessions/{id}/alias ← 接受(**与"人手改别名"同一个端点**) + * + * ★ 为什么"接受"不另开端点(服务端注释原话): + * 「那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方」。 + */ + const model = code(join(HARMONY_ETS, 'model/SessionRename.ets')); + const api = code(join(HARMONY_ETS, 'api/SessionApi.ets')); + + /* ① 字段名逐字对齐(服务端是 alias / reason,不是 newAlias / why) */ + assert.match(model, /alias: string = ''/, + '★ 模型字段要叫 `alias`(与服务端 `RenameProposal.Alias` 的 json tag 一致)'); + assert.match(model, /reason: string = ''/, + '★ 模型字段要叫 `reason`(服务端 json tag 是 `reason,omitempty`)'); + assert.ok(!/newAlias|why:/.test(model), + '★ 不许自造字段名 —— 服务端不认识的名字会被静默忽略(读出来永远是空)'); + + /* ② 响应外壳:服务端把 proposal 包在一层里(且用 `null` 表示"没有建议") */ + assert.match(api, /proposal: RenameProposal \| null = null/, + '★ 响应外壳必须容忍 `null` —— 服务端在有/无建议时分别返回对象与 `null`,' + + '而不是 404("没有建议"是正常状态)'); + + /* ③ 接受走**别名端点**(与手改别名同一条路) */ + assert.match(api, /\/sessions\/' \+ sessionId \+ '\/alias'/, + '★ 接受建议要走 `PUT /sessions/{id}/alias` —— ' + + '服务端特意不另开端点(唯一性校验与 409 处理只该有一处)'); + assert.match(api, /session_alias: alias/, + '★ 别名端点的请求体字段是 `session_alias`(服务端 json tag)'); + + /* ④ 驳回走**专用端点**(它不是改别名,是"别再问了") */ + assert.match(api, /rename-proposal\/dismiss/, + '★ 驳回要走专用端点 `/rename-proposal/dismiss` —— ' + + '服务端记下被驳回的别名,否则每次打开会话都要重新点一次「忽略」'); + + /* ⑤ 方法名与语义一致(accept 不是 update、dismiss 不是 delete) */ + assert.match(api, /async acceptRename\(/, '要有 `acceptRename`(与 WebUI store 同名)'); + assert.match(api, /async dismissRename\(/, '要有 `dismissRename`(与 WebUI store 同名)'); + + /* ⑥ 锚点自检:把服务端路径改错必须能被抓到 */ + const mutated = api.replace("/sessions/' + sessionId + '/alias'", "/sessions/' + sessionId + '/alias-wrong'"); + assert.notEqual(mutated, api, '变异要有实际效果(锚点必须命中)'); + assert.ok(!/\/sessions\/' \+ sessionId \+ '\/alias'/.test(mutated), + '★ 路径写错时上面那条断言必须能判红'); +}); diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index dda0e64..1c7f1c7 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -78,7 +78,7 @@ const SUITE = [ // 预设的**行为**判据:每一档都真的画得出来(能真跑,不需要设备 ⇒ 不进 static 欠账)。 // 与 appearance-defaults 那条「清单 id/顺序相等」配对:值判据管清单,行为判据管渲染器。 ['test/harmony-presets.test.mjs', ['--experimental-strip-types', '--no-warnings'], 6], - ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 30], + ['test/harmony-logic.test.mjs', ['--experimental-strip-types', '--no-warnings'], 31], ['test/harmony-system-api.test.mjs', [], 5], // P4 外观同步:跑 model/Appearance.ts(纯逻辑),所以也要 strip-types ['test/harmony-appearance.test.mjs', ['--experimental-strip-types', '--no-warnings'], 27], diff --git a/client/harmony/entry/src/main/ets/api/SessionApi.ets b/client/harmony/entry/src/main/ets/api/SessionApi.ets new file mode 100644 index 0000000..9df13d4 --- /dev/null +++ b/client/harmony/entry/src/main/ets/api/SessionApi.ets @@ -0,0 +1,59 @@ +/** + * 会话级接口(`/sessions/{id}/...`)。 + * + * 为什么单独一个文件:会话与**邮件**是两个生命周期不同的东西 —— + * 邮件是内容,会话是"一条任务"(有别名、有往返预算、有权限档)。 + * 混进 `MailApi` 会让"这条路径属于谁"变得含糊(本仓已有一次 + * `MeApi` 被塞进 `AdminApi.ets` 的教训,那是历史原因,不重复)。 + */ +import { ApiClient } from './ApiClient'; +import { RenameProposal } from '../model/SessionRename'; + +/** `GET /sessions/{id}/rename-proposal` 的响应外壳 */ +class RenameProposalEnvelope { + /** `null` = 没有待处理的建议(服务端用 null 而不是 404,是"正常状态") */ + proposal: RenameProposal | null = null; +} + +export class SessionApi { + private client: ApiClient; + + constructor(client: ApiClient) { + this.client = client; + } + + /** + * 读一条会话的**改名建议**(Agent 在正文里提的)。 + * + * 服务端从正文里解析 `` + * 标记(`rename_proposal.go`),并把标记从正文剥掉 —— 所以页面拿到的是 + * "建议"与"干净正文"两件事。 + */ + async getRenameProposal(sessionId: string): Promise { + const resp: RenameProposalEnvelope = + await this.client.get('/sessions/' + sessionId + '/rename-proposal'); + return resp.proposal; + } + + /** + * **接受**建议。 + * + * ★ 接受走的是 `PUT /sessions/{id}/alias`(与"人手改别名"**同一个端点**)—— + * WebUI 侧也是这么做的(`sessionStore.acceptRename` 调 `updateSessionAlias`)。 + * 不为"接受建议"单开一个端点:那会让同一件事有两条实现, + * 而别名冲突(409)的判定只需一处。 + */ + async acceptRename(sessionId: string, alias: string): Promise { + await this.client.put('/sessions/' + sessionId + '/alias', { session_alias: alias }); + } + + /** + * **驳回**建议。服务端记下来,不再反复弹同一个。 + * + * 与"接受"不同,这条有**专用端点**(`/rename-proposal/dismiss`)—— + * 因为"驳回"这件事在别名端点里没有对应物(它不是改别名,是"别再问了")。 + */ + async dismissRename(sessionId: string): Promise { + await this.client.post('/sessions/' + sessionId + '/rename-proposal/dismiss', {}); + } +} diff --git a/client/harmony/entry/src/main/ets/model/SessionRename.ets b/client/harmony/entry/src/main/ets/model/SessionRename.ets new file mode 100644 index 0000000..6c1deaf --- /dev/null +++ b/client/harmony/entry/src/main/ets/model/SessionRename.ets @@ -0,0 +1,19 @@ +/** + * 会话改名建议(Agent 在正文里提的)。 + * + * 服务端形状见 `server/internal/handler/rename_proposal.go`: + * 正文里的 HTML 注释标记 + * `` + * 被解析成这个结构,标记本身从正文剥掉。 + * + * ★ 为什么这件事重要(WebUI 注释里的原话): + * 「Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效」—— + * 所以是**提议 + 人确认**,而不是 Agent 单方面改。 + * 人的确认是**寻址稳定性**的最后一道。 + */ +export class RenameProposal { + /** 建议的新别名(不带点;页面上显示成 `.xxx`) */ + alias: string = ''; + /** Agent 给的理由(可空) */ + reason: string = ''; +}