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)。
80 lines
3.7 KiB
TypeScript
80 lines
3.7 KiB
TypeScript
/**
|
||
* 背景选择器的**界面可达性**判据。
|
||
*
|
||
* 为什么需要这一层:`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');
|
||
});
|
||
});
|