- 新增布局管理器支持拖拽调整面板宽度 - 新增状态管理器实现布局持久化 - 重构前端页面结构优化四栏式布局 - 新增 Jest 测试框架和完整测试用例 - 完善项目文档(PRD/FD/TDD/TODO/LOG) - 清理 Python 缓存文件并更新 .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
267 lines
8.7 KiB
JavaScript
267 lines
8.7 KiB
JavaScript
/**
|
||
* 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
|
||
});
|
||
}
|
||
});
|
||
});
|