Files
胡绍康andCopilot cf7817bb95 feat: UI全局美化重设计 + 恢复CI/测试/文档
UI 改进:
- 设置页面内嵌显示(不再弹窗)
- 临时邮箱卡片添加验证码提取按钮
- 导入账号弹窗 textarea 样式优化
- 账号头像 8 色渐变轮转
- 临时邮箱页顶栏邮箱点击复制
- 邮箱管理页自动选中默认分组
- 切换分组时重置邮件列 UI
- 修复收件箱/垃圾邮件 Tab 切换(.folder-tab → .email-tab)
- 修复从临时邮箱返回后账号列数据残留
- 仪表盘分组列表可点击导航

恢复:
- 恢复 .github/ CI 工作流
- 恢复 tests/ 测试目录
- 恢复 docs/ 文档目录
- 恢复 CONTRIBUTING.md, SECURITY.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-01 21:24:06 +08:00

267 lines
8.7 KiB
JavaScript
Raw Permalink 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.
/**
* StateManager 单元测试(TASK-04-001)
*/
const StateManager = require('../../../static/js/state-manager.js');
const { createTestState } = require('../test-utils.js');
describe('StateManager 单元测试(TASK-04-001)', () => {
let stateManager;
beforeEach(() => {
stateManager = new StateManager();
});
test('getStorageKey() 应支持 userId 与 guest', () => {
expect(stateManager.getStorageKey('user123')).toBe('outlook_layout_state_user123');
expect(stateManager.getStorageKey(' userA ')).toBe('outlook_layout_state_userA');
expect(stateManager.getStorageKey('')).toBe('outlook_layout_state_guest');
expect(stateManager.getStorageKey(null)).toBe('outlook_layout_state_guest');
});
test('save()/load() 应能完成状态保存与读取', () => {
const userId = 'user123';
const state = createTestState(userId);
const ok = stateManager.save(userId, state);
expect(ok).toBe(true);
const loaded = stateManager.load(userId);
expect(loaded).toBeTruthy();
expect(loaded.version).toBe('1.1');
expect(loaded.userId).toBe(userId);
expect(loaded.panels.groups.width).toBe('200px');
expect(loaded.panels.groups.collapsed).toBe(false);
});
test('clear() 应清除指定用户的状态', () => {
const userId = 'user123';
const state = createTestState(userId);
stateManager.save(userId, state);
expect(stateManager.load(userId)).toBeTruthy();
stateManager.clear(userId);
expect(stateManager.load(userId)).toBeNull();
});
test('validate() 应验证数据格式', () => {
const good = createTestState('u1');
expect(stateManager.validate(good)).toBe(true);
const badWidth = createTestState('u1');
badWidth.panels.groups.width = '200';
expect(stateManager.validate(badWidth)).toBe(false);
const badCollapsed = createTestState('u1');
badCollapsed.panels.groups.collapsed = 'yes';
expect(stateManager.validate(badCollapsed)).toBe(false);
expect(stateManager.validate(null)).toBe(false);
expect(stateManager.validate({ version: '1.1' })).toBe(false);
});
test('migrate() 应补齐/修正 version', () => {
const migrated1 = stateManager.migrate({ panels: createTestState('u1').panels });
expect(migrated1.version).toBe('1.1');
const migrated2 = stateManager.migrate({ version: '1.0', panels: createTestState('u1').panels });
expect(migrated2.version).toBe('1.1');
});
test('load() JSON 损坏时应清除并返回 null', () => {
const userId = 'u1';
const key = stateManager.getStorageKey(userId);
window.localStorage.setItem(key, '{bad json');
const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const loaded = stateManager.load(userId);
expect(loaded).toBeNull();
expect(window.localStorage.getItem(key)).toBeNull();
errSpy.mockRestore();
});
test('save() localStorage 不可用时应返回 false', () => {
const userId = 'u1';
const state = createTestState(userId);
const originalLocalStorage = window.localStorage;
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: null
});
try {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
expect(stateManager.save(userId, state)).toBe(false);
warnSpy.mockRestore();
} finally {
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: originalLocalStorage
});
}
});
test('getStorage() 在访问 localStorage 抛错时应返回 null', () => {
const originalLocalStorage = window.localStorage;
Object.defineProperty(window, 'localStorage', {
configurable: true,
get() {
throw new Error('blocked');
}
});
expect(stateManager.getStorage()).toBeNull();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: originalLocalStorage
});
});
test('isQuotaExceededError() 应识别不同浏览器的 quota 错误形态', () => {
expect(stateManager.isQuotaExceededError(null)).toBe(false);
expect(stateManager.isQuotaExceededError({ name: 'QuotaExceededError' })).toBe(true);
expect(stateManager.isQuotaExceededError({ name: 'NS_ERROR_DOM_QUOTA_REACHED' })).toBe(true);
expect(stateManager.isQuotaExceededError({ code: 22 })).toBe(true);
expect(stateManager.isQuotaExceededError({ code: 1014 })).toBe(true);
expect(stateManager.isQuotaExceededError({ name: 'OtherError', code: 123 })).toBe(false);
});
test('clearOldStates() 应清理旧状态并保留 excludeKey', () => {
const k1 = 'outlook_layout_state_u1';
const k2 = 'outlook_layout_state_u2';
const excludeKey = 'outlook_layout_state_keep';
const otherKey = 'not_outlook_key';
window.localStorage.setItem(k1, JSON.stringify({ timestamp: 1 }));
window.localStorage.setItem(k2, '{bad json'); // 解析失败应仍被清理
window.localStorage.setItem(excludeKey, JSON.stringify({ timestamp: 999 }));
window.localStorage.setItem(otherKey, 'x');
stateManager.clearOldStates(excludeKey);
expect(window.localStorage.getItem(k1)).toBeNull();
expect(window.localStorage.getItem(k2)).toBeNull();
expect(window.localStorage.getItem(excludeKey)).toBeTruthy();
expect(window.localStorage.getItem(otherKey)).toBe('x');
});
test('save() 遇到 QuotaExceededError 时应清理旧数据并重试', () => {
const userId = 'u1';
const state = createTestState(userId);
const key = stateManager.getStorageKey(userId);
// 使用可控的 storage stub:第一次 setItem 抛 QuotaExceededError,第二次成功
const store = {};
let throwOnce = true;
const storageStub = {
setItem: jest.fn((k, v) => {
if (throwOnce) {
throwOnce = false;
const err = new Error('quota');
err.name = 'QuotaExceededError';
throw err;
}
store[k] = String(v);
}),
getItem: jest.fn((k) => store[k] || null),
removeItem: jest.fn((k) => {
delete store[k];
})
};
const getStorageSpy = jest.spyOn(stateManager, 'getStorage').mockReturnValue(storageStub);
const clearSpy = jest.spyOn(stateManager, 'clearOldStates').mockImplementation(() => {});
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ok = stateManager.save(userId, state);
expect(ok).toBe(true);
expect(storageStub.setItem).toHaveBeenCalledTimes(2);
expect(clearSpy).toHaveBeenCalledWith(key);
expect(storageStub.getItem(key)).toBeTruthy();
} finally {
warnSpy.mockRestore();
clearSpy.mockRestore();
getStorageSpy.mockRestore();
}
});
test('save() 数据格式无效时应返回 false(不写入 storage)', () => {
const userId = 'u1';
const badState = createTestState(userId);
badState.panels.groups.width = '200'; // 非 px
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const ok = stateManager.save(userId, badState);
expect(ok).toBe(false);
expect(window.localStorage.getItem(stateManager.getStorageKey(userId))).toBeNull();
} finally {
warnSpy.mockRestore();
}
});
test('load() 数据校验失败时应清除并返回 null', () => {
const userId = 'u1';
const key = stateManager.getStorageKey(userId);
window.localStorage.setItem(
key,
JSON.stringify({
version: '1.1',
userId,
timestamp: Date.now(),
panels: {
groups: { width: '200', collapsed: false }, // invalid
accounts: { width: '260px', collapsed: false },
emails: { width: '380px', collapsed: false }
}
})
);
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
try {
const loaded = stateManager.load(userId);
expect(loaded).toBeNull();
expect(window.localStorage.getItem(key)).toBeNull();
} finally {
warnSpy.mockRestore();
}
});
test('load() storage.getItem 抛错时应返回 null(不抛异常)', () => {
const userId = 'u1';
const originalLocalStorage = window.localStorage;
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
getItem: () => {
throw new Error('denied');
},
setItem: () => {},
removeItem: () => {},
key: () => null,
get length() {
return 0;
}
}
});
try {
expect(stateManager.load(userId)).toBeNull();
} finally {
warnSpy.mockRestore();
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: originalLocalStorage
});
}
});
});