Files
MailUI4Agents/client/electron/test/components/BackgroundPicker.test.tsx
JianFeeeee e94313f66a 跨端: 接手 pi 的两个 WebUI 开项——默认值统一到服务端契约 12/4;缓存键按账号(含一次性迁移)
pi 问"这两个开项谁执行",我接了(他那边无 shell,我这边改过 WebUI)。两件都是他读源码读出来的实缺陷。

## 1 默认值:不是审美,是**服务端契约**(pi 更正了自己上一封)

`server/internal/models/models.go` 的 `DefaultAppearance()` 明写 `BgDim: 12, BgBlur: 4`,
且注释宣称"与客户端 backgroundStore / themeStore 的默认值一致"——而 WebUI 的
`backgroundStore.ts` 是 `dim: 24, blur: 8`,**那句注释是假的**;`lib/appearance.ts`
的 `clamp(..., 12, 4)` 又是另一套。**同一份代码里两个"默认值"**,走哪条路就落哪个数。

后果不是"两处代码不一样"这么轻:服务端"没有记录"时客户端以本地为准推上去,
于是**新账号的初始外观由第一个同步它的客户端决定**(先 WebUI 登录存 24/8,
先鸿蒙登录存 12/4)——同一个账号,压暗强度取决于谁先到。

改法:新增 `src/lib/appearanceDefaults.ts` 作为**唯一来源**(DEFAULT_DIM/DEFAULT_BLUR/
上限),`backgroundStore` 与 `lib/appearance` 都引用它,字面量全部消失。

## 2 缓存键按账号(含旧全局键的一次性迁移)

`STORAGE_KEY = 'agentmail.background'` → `storageKey(accountId)` = 前缀 + 账号;
写盘只走 `storageKey()`;旧全局键**只作为迁移源**:当前账号首次读到它时接管并存进自己的键,
然后**立刻删除**(否则下一个账号继续从它"继承",等于把刚修的缺陷留在原地);
未登录时不迁移(旧值不能送给一个还不知道是谁的账号)。

配套顺序:`appearanceSync` 在账号切换时**先 `reloadForAccount()` 再 `pull()`** ——
服务端"没有记录"时 `pull()` 会"以本地为准推上去",那时"本地"必须已经是本账号的值。

## 3 判据(新增第 13 个判据文件 appearance-defaults)

`test/appearance-defaults.test.mjs`:**去 Go 源码里读** `DefaultAppearance()` 的四个字段,
再比对三处(WebUI 常量、store 的 DEFAULT_BACKGROUND 不许有字面量、鸿蒙 Appearance 的字段默认值);
另两条钉"键按账号、不许退回全局键、旧键必须被删除"与"重读在 pull 之前"。
这样服务端那句注释是**可核对**的,不是承诺。

顺带更正:`Wallpaper.ts` 里"WebUI 默认 24"的注释已过时 → 改 12 并写明缘由;
`harmony-appearance` 里"WebUI 是全局键"的前提失效 → 改为断言两端都按账号分键。

## 验证

`npm test` 退出码 0(13 个判据文件全绿 + vitest 263 passed,原 258 + 新增 5 条行为测试:
键隔离、迁移一次并删除、未登录不迁移、默认值=12/4)。
2026-09-14 15:17:01 +08:00

80 lines
3.7 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 { LEGACY_STORAGE_KEY, 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');
});
});