Files
MailUI4Agents/client/electron/test/nav-merge.test.mjs
JianFeeeee d5cfcbdc9c fix(权限): 409 的第二种含义是「本档不该问」——四桥都补上;状态写入点不再兜默认档
线上事故(jianf 经 pi 转达):补投路径漏传 permission_mode,插件拿 undefined 兜了
workspace 档,把 full 档会话写成 workspace-write + ask —— 不是"拦一次",是一整轮
工具能力降级,且状态留在会话里;随后该会话每次受守卫调用都撞 409。

四件事:

1. **状态写入点不接受默认值**(新增共享 `modeForStateWrite`):缺字段/脏值 → `null`
   = 不写状态。"默认值可以出现在**决策**里,不可以出现在**状态写入**里。"
   同时保留共享契约的 fail-closed:真读到 workspace 才写 workspace。

2. **409 的两种含义分开处理**。`allowed-once` 只绕过**审批**,改不了**沙箱** ——
   所以 dsh 桥在放行前先把服务端给的权威档位**写回会话**(这也就成了自愈路径:
   已经降级的会话,下一次带档位的 409 会把它修回来);只认服务端明说的 full,
   plan 与"链上没有人类"照旧 fail closed。

3. **同一处缺陷在 zcode / opencode 也在**(`hooks/permission.mjs` 与 `index.js`
   都把 409 当永久失败拒绝)。我先前在回信里写过"这两个桥不转发权限询问,不需要改"
   —— 那句话是错的,我当时的搜索面只有 `<plugin>/src/*.mjs`。按 pi 的要求把这条
   **否定性事实变成常驻判据**后,它第一次运行就红给我看。四桥现在都有
   「409 + full → 放行」,且**排在永久失败分支之前**(含顺序变异自检)。

4. **共用测试重新同源**:`test/catchup.test.mjs` 从 `153985e` 起就是分叉的
   (我那版把平台专属路径写进了共用文件),而 `deploy/install.sh` 第 24 行会跑
   `check-shared-libs.sh` —— 也就是说**部署一直是红的**,我没跑过那个脚本。
   共用文件只放契约(值/行为),跨平台配对judge 移到平台专属文件,四份逐字节相同。

另外把"判代码 vs 判理由"从记忆变成代码:`test/lib/read.mjs` 提供 `code()/prose()/bytes()`,
判据目录里不得再裸用 `readFileSync`(新判据 `criteria-hygiene` 管,含读取器自检)。

判据证据(每条都做过"能不能红"的变异):
- 写回去掉 → 红;纠正块挪到普通 409 之后 → 红;状态写入点退回兜默认 → 红;
- zcode/opencode 的放行分支拿掉 → 各自红;共用测试分叉 → check-shared-libs 红。

各套件:dsh 388、pi 443、zcode 387、opencode 333(均经 npm test,含 tsc);
electron `npm test` 15/15 判据绿 + vitest 266 + typecheck;`check-shared-libs.sh` 退出 0;
Go `go test ./...` 全 ok。
2026-09-14 16:21:27 +08:00

128 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 导航重构的判据2026-09-14 用户三条要求)。
*
* 用户原话:
* ①「导航栏的内容有点多了,收件发件授权改为一个导航项,通过内部导航区分,
* 然后新建作为他们内部的一个悬浮的圆形加号,这样导航项就只剩通信,日历,联系人」
* ②「将管理和我的合并,管理员视角我的页面拉到最下面有一个管理」
* ③「复选框和地址猜测项的透明度问题还没改,他们才是真正需要拉低透明度的地方」
*
* 为什么写文件级判据:改完跑老套件 254 条**全绿**,因为它一条都没测导航结构 ——
* 我差点把"没红的测试"当成"改对了"。结构类改动必须自己带判据。
*/
import { code } from './lib/read.mjs';
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const read = (...p) => code(join(HERE, '..', 'src', ...p));
const sidebar = read('components', 'Sidebar.tsx');
const narrow = read('components', 'NarrowNav.tsx');
const commTabs = read('components', 'CommTabs.tsx');
const app = read('App.tsx');
const account = read('components', 'AccountPage.tsx');
const uiStore = read('stores', 'uiStore.ts');
const css = read('index.css');
const mailView = read('components', 'MailView.tsx');
const addr = read('components', 'AddressInput.tsx');
// 「授权多选胶囊」现在住在共用组件里MailView 只是用它)
const chip = read('components', 'Composer.tsx');
test('① 导航只剩 通信/日历/联系人 三项(桌面与窄屏都要)', () => {
for (const [name, src] of [['Sidebar', sidebar], ['NarrowNav', narrow]]) {
// 三项都在
assert.match(src, /短:\s*'通信'|short:\s*'通信'/, `${name} 缺「通信」`);
assert.match(src, /'日历'/, `${name} 缺「日历」`);
assert.match(src, /'联系人'|'联系'/, `${name} 缺「联系人」`);
// 旧的独立项必须消失
for (const gone of ['收件', '发件', '授权请求', '用户管理']) {
assert.ok(!src.includes(`'${gone}'`), `${name} 里还留着旧的独立导航项「${gone}`);
}
}
});
test('① 「通信」的选中态覆盖三个子页签,点击回到上次那个', () => {
assert.match(sidebar, /modes:\s*\['inbox',\s*'sent',\s*'permissions'\]/);
assert.match(narrow, /modes:\s*\['inbox',\s*'sent',\s*'permissions'\]/);
/*
* 判据要的是**行为**:点「通信」(没有明确 target 时)回到上次那个子页签。
*
* 这里原先钉的是一条字面表达式 `setViewMode(target || commTab`,而代码后来写成了
* `setViewMode(target ?? (isComm ? commTab : modes[0]))` —— 行为一样(点通信回 commTab
* 点日历/联系人落到该项第一个模式),字面却不一样,于是判据红、代码没错。
* 钉字面表达式的判据会在每次等价改写时误报;钉"isComm 时落到 commTab"这个行为不会。
*/
assert.match(sidebar, /setViewMode\([^)]*isComm\s*\?\s*commTab/, '桌面点通信要回 commTab');
const desktopHandler = sidebar.match(/setViewMode\([^)]*isComm\s*\?\s*commTab[^)]*\)/)[0];
assert.match(desktopHandler, /target/, '桌面点非通信项时要有明确的 target否则日历/联系人点不动)');
assert.match(narrow, /setViewMode\(isComm \? commTab : mode\)/, '窄屏点通信要回 commTab');
});
test('① 内部导航CommTabs存在、挂在通信页、且带徽标', () => {
assert.match(commTabs, /data-testid="comm-tabs"/);
assert.match(commTabs, /'收件箱'[\s\S]*'发件箱'[\s\S]*'授权'/, '三个内部页签');
assert.match(commTabs, /pendingPerms/, '授权徽标不能因为合并而消失');
assert.match(app, /<CommTabs \/>/, 'App 里要渲染内部导航');
assert.match(app, /const isComm = viewMode === 'inbox'/, '只挂在通信三个页签下');
});
test('① 新建是悬浮圆形加号,且不再是导航项', () => {
assert.match(commTabs, /data-testid="compose-fab"/);
assert.match(commTabs, /rounded-full/, '必须是圆的(用户点名"圆形加号"');
assert.match(commTabs, /absolute bottom-4 right-4/, '悬浮在列表栏右下角');
assert.match(app, /<ComposeFab \/>/, 'App 里要渲染它');
assert.ok(!/新建<\/span>/.test(sidebar), '桌面侧栏不该再有为"新建"的导航文字');
assert.ok(!/新建<\/span>/.test(narrow), '窄屏导航不该再有"新建"');
});
test('② 管理与我的合并:入口在「我的」最下面,且仅管理员可见', () => {
assert.match(account, /user\?\.role === 'admin' &&/, '必须是条件渲染而不是禁用');
const logout = account.indexOf('退出登录');
const adminEntry = account.indexOf("setViewMode('admin')");
assert.ok(logout > 0 && adminEntry > logout, '管理入口要在「登录状态」之后(=页面最下面)');
assert.ok(!/mode: 'admin'/.test(sidebar), '侧栏不该再有独立的 admin 导航项');
assert.ok(!/mode: 'admin'/.test(narrow), '窄屏不该再有独立的 admin 导航项');
});
test('② uiStore 记住通信子页签,且 reset 能清掉', () => {
assert.match(uiStore, /commTab: CommTab/);
assert.match(uiStore, /setCommTab/);
assert.match(uiStore, /commTab: mode as CommTab/, 'setViewMode 要顺手记住');
assert.match(uiStore, /reset:[\s\S]{0,120}commTab: 'inbox'/, '登出后要复位');
});
test('③ 复选框与地址建议用"控件档"透度(比正文面更透)', () => {
/*
* 判据要的是**那颗胶囊的样式**,不是"MailView.tsx 这个文件里出现过 glass-control"。
*
* 授权多选的选项胶囊后来抽成了共用组件 `ComposerChip`MailView 与 Composer 共用),
* 于是"在 MailView.tsx 里 grep glass-control"必然红 —— 而实际样式是对的
* ComposerChip 的 neutral 未选中态就是 glass-control。文件是会搬的
* 组件不会:所以这里先钉"MailView 用的是 ComposerChip",再钉"ComposerChip 用控件档"。
*/
assert.match(mailView, /import \{[^}]*ComposerChip[^}]*\} from '\.\/Composer'/, 'MailView 要用共用的胶囊组件');
assert.match(mailView, /<ComposerChip/, '授权选项必须是那颗胶囊');
assert.match(chip, /glass-control/, '授权多选胶囊ComposerChip 未选中态)要用控件档');
assert.match(addr, /glass-control/, '地址建议菜单');
assert.ok(!/glass-control[^']*bg-white/.test(chip), '胶囊不能再同时写 bg-white');
assert.ok(!/glass-control[^"]*bg-white/.test(addr), '建议菜单不能再同时写 bg-white');
// 三档透度必须真的递减(正文 > 嵌套 > 控件)
const num = re => Number(css.match(re)[1]);
const big = num(/--bg-glass:\s*(0\.\d+)/);
const inner = num(/--bg-glass-inner:\s*(0\.\d+)/);
const ctrl = num(/--bg-glass-control:\s*(0\.\d+)/);
assert.ok(big > inner && inner > ctrl, `三档应递减:${big} > ${inner} > ${ctrl}`);
assert.ok(ctrl <= 0.6, `控件档要明显更透(当前 ${ctrl}`);
});
test('★ 判据自检:拿重构前的写法喂进来必须判红', () => {
const oldSidebar = `{ short: '收件', mode: 'inbox', Icon: InboxIcon },
{ short: '授权', title: '授权请求', mode: 'permissions', Icon: ShieldIcon }`;
assert.ok(oldSidebar.includes("'授权请求'"), '旧写法能被本判据的"必须消失"条款命中');
const oldChip = "'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'";
assert.ok(!/glass-control/.test(oldChip), '旧胶囊写法不含 glass-control ⇒ 会判红');
});