Files
MailUI4Agents/client/electron/test/components/BackgroundPicker.test.tsx
JianFeeeee 71a3c35610 按 pi 复核改五处:判据的"值/行为"原则、真正的构建门、typecheck 进门、Go 判据零匹配、迁移归属
pi 用反例与变异逐条点了五处,全部**先跑变异再改**(结论都写在原地)。

## 1 判据③:形状正则已删(它永远差一个反例)

实测 pi 的反例 —— `const k = PREFIX + accountId; return PREFIX;`(拼了但没返回)——
对第三版判据**仍然全绿**:第三版锚的是"**函数体里存在**这样的表达式",不是"**返回的**表达式"。
三版的骗法各一个(签名里的参数 / 提了一下没用 / 拼了没返回),都在判"源码里有没有那个形状",
而缺陷是"算出来的值对不对" ⇒ **权威交给行为判据**(`test/stores/background.test.ts` 直接断言
`storageKey('a') !== storageKey('b')`、`=== 'agentmail.background.acct-a'`、不退回全局键),
正则那条删掉并把反例写在原地(否则下一个人会好心加回来)。
保留"键必须来自 storageKey()"那条:那是**来源**约束,不是值对不对,正则在这里合适。
鸿蒙侧只能静态判(`.ets` 本机没有运行时),已把这条限制写进判据说明。
推广进规范:CRITERIA.md §6.7 + run-all 自检关键词(10 → 11)。

## 2 真正的门:`scripts/release-linux.sh`

- 实测 pi 提的变异:**`touch dist/index.html` 时 stamp 判据是绿的** —— 它抓不到"构建失败但碰过 dist"。
  stamp 是**探测器**(抓"src 改了产物没跟上"),门是**喂退出码**,两者不互替(§6.7.1)。
- `build:linux` 里**没有管道**(`&&` 链,退出码本来就传),但那次的哑巴失败是我在命令行手打
  `npm run build 2>&1 | tail -4 && …` 造成的;同时发现它跑的是**裸 `vite build`,跳过 `gen:bg`**。
- 于是把配方收成 `scripts/release-linux.sh`:`set -euo pipefail` + 走 `npm run build` + 再打包。
- 判据是**行为**的:注入失败的构建(`AGENTMAIL_BUILD_CMD='exit 7'`)→ 断言退出码非 0
  **且打包那步没跑**(标记文件不存在)。变异:脚本改成 `|| true` → 红 ✓。

## 3 typecheck 进 `npm test` 链

`npm run typecheck` 原本就有,但没人跑。先修掉它唯一的报错(我自己留下的未使用 import),现在干净;
`test` = run-all + vitest + typecheck。它恰好检查**没被任何测试 import 的文件**(vitest 只解析被测到的图)——
也就是那次 `is not exported by` 的形状。

## 4 Go 源码判据:零匹配 / 读不懂 都要红

改成三分:切不出函数体 → 红;字段在但值不是字面量 → 报「**判据读不懂**」(变异:`BgDim: defaultDim` → 红 ✓);
字段不在 → 红。静默放行是这类判据最危险的失败方式。
**措辞更正**:这条核对的是"与**这份服务端源码**的契约",不是"在跑的服务端二进制是 12/4"
(与"dist 是产物"同构);文档同步改。

## 5 迁移归属:定向做不到,就把"不可恢复"降级成"可恢复"

查实:`accountStore` 的 `activeId` 是**派生视图状态、不落盘**(`persist(accounts)` 只存数组),
所以**本机没有"上次活跃账号"标记可定向** —— 旧值的作者事后无法还原,定向迁移在原理上做不到。
升级前用 B、升级后先登录 A ⇒ A 接管 B 的外观,**一次性错档**,触发条件就这一条。
两件能做的都做了:**写了回读校验**(写不进去就不删旧键,避免净损失;变异:改成先删后写 → 2 条红 ✓)、
**删前另存** `agentmail.background.legacy.bak`(只写不读 ⇒ 不引入新的继承源,判据钉"只写不读")。
§7.12 把本地这半与显形方式写进同一行。

## 验证

`npm test` 退出码 0:14 个判据文件全绿 + vitest **265** 通过(+2 迁移行为测试)+ typecheck 干净;
`hvigorw assembleHap` BUILD SUCCESSFUL;安装包经新脚本重打(dist 与包同批)。
2026-09-14 15:41:52 +08:00

80 lines
3.6 KiB
TypeScript
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.

/**
* 背景选择器的**界面可达性**判据。
*
* 为什么需要这一层:`stores/background.test.ts` 只钉了 store 的状态机,
* 而线上真实缺陷恰好落在"状态 → 控件"这一跳上 ——
* 点「图片」时 kind 被折叠回 `none`,于是上传控件(只在 `kind === 'image'`
* 下渲染)**从不出现**store 的单测全绿,用户却完全无法自定义背景。
*
* 所以这里测的是"用户走得到走不到":点「图片」→ 选文件按钮必须出现 →
* 选完图背景必须真的亮起来。
*/
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BackgroundPicker from '../../src/components/BackgroundPicker';
import { storageKey, useBackgroundStore } from '../../src/stores/backgroundStore';
import * as store from '../../src/stores/backgroundStore';
describe('BackgroundPicker', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.removeAttribute('data-bg');
document.documentElement.className = '';
document.documentElement.removeAttribute('style');
useBackgroundStore.setState({ ...store.DEFAULT_BACKGROUND });
vi.restoreAllMocks();
});
it('★ 点「图片」后必须出现选文件控件(否则自定义背景根本走不到)', async () => {
render(<BackgroundPicker />);
// 初始是「无」:没有文件输入
expect(document.querySelector('input[type=file]')).toBeNull();
await userEvent.click(screen.getByRole('button', { name: '图片' }));
expect(useBackgroundStore.getState().kind).toBe('image');
expect(document.querySelector('input[type=file]')).not.toBeNull();
expect(screen.getByRole('button', { name: /选择图片|更换图片/ })).toBeInTheDocument();
});
it('★ 选完图片后背景真的亮起来,并落盘', async () => {
vi.spyOn(store, 'prepareImage').mockResolvedValue({
ok: true,
dataUrl: 'data:image/jpeg;base64,ZZZZ'
});
render(<BackgroundPicker />);
await userEvent.click(screen.getByRole('button', { name: '图片' }));
const input = document.querySelector('input[type=file]') as HTMLInputElement;
const file = new File(['x'], 'bg.jpg', { type: 'image/jpeg' });
await userEvent.upload(input, file);
await waitFor(() => expect(document.documentElement.dataset.bg).toBe('on'));
expect(document.documentElement.style.getPropertyValue('--bg-image')).toContain('ZZZZ');
expect(JSON.parse(localStorage.getItem(storageKey(''))!).kind).toBe('image');
});
it('图片准备失败时说出原因,并且不假装成功', async () => {
vi.spyOn(store, 'prepareImage').mockResolvedValue({ ok: false, reason: '图片过大(超过 20MB请先裁剪' });
render(<BackgroundPicker />);
await userEvent.click(screen.getByRole('button', { name: '图片' }));
const input = document.querySelector('input[type=file]') as HTMLInputElement;
await userEvent.upload(input, new File(['x'], 'bg.jpg', { type: 'image/jpeg' }));
expect(await screen.findByRole('alert')).toHaveTextContent('图片过大');
// 没换成就不该亮背景
expect(document.documentElement.dataset.bg).toBe('off');
});
it('「无」把背景关掉(对照:图片档不是永久卡住)', async () => {
useBackgroundStore.getState().setImage('data:image/png;base64,YYYY');
render(<BackgroundPicker />);
await userEvent.click(screen.getByRole('button', { name: '无' }));
expect(useBackgroundStore.getState().kind).toBe('none');
expect(document.documentElement.dataset.bg).toBe('off');
});
});