All files state-manager.js

91.17% Statements 124/136
85.55% Branches 77/90
100% Functions 13/13
94.87% Lines 111/117

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321                          11x     11x 11x               127x 127x   127x   1x                   132x 132x                   35x 35x 1x   1x       1x     34x             34x 1x   1x       1x     33x 33x   33x 33x 31x     2x 1x   1x       1x 1x 1x 1x                         1x   1x       1x                   86x 86x                   86x   86x 86x 86x   3x   3x       3x     83x   6x 6x 6x     2x 2x       2x   2x       2x     4x 4x 1x 1x       1x   1x       1x     3x                 5x 5x 5x 5x 5x                       8x 7x 7x 5x 4x   4x                 1x 1x   1x 1x 1x 4x 4x 4x 3x 2x             1x 2x 2x 2x 2x 2x 1x     1x   2x     1x   1x 2x 2x                         43x 42x   41x 41x 115x 115x 115x 112x     37x                 6x     6x 1x       5x     1x         11x     11x 11x      
/**
 * StateManager - 布局状态持久化管理器
 *
 * 职责(按文档逐步实现):
 * - 将 LayoutState 保存到 localStorage(多用户隔离 key)
 * - 从 localStorage 加载并验证 LayoutState
 * - 提供版本迁移能力(migrate)
 *
 * 兼容性说明:
 * - 浏览器环境:通过 <script> 引入后,StateManager 挂载到 window.StateManager。
 * - 测试/Node 环境:如果存在 module.exports,则导出 StateManager 以便 Jest 直接 require。
 */
 
(function (global) {
  'use strict';
 
  const CURRENT_VERSION = '1.1';
  const STORAGE_KEY_PREFIX = 'outlook_layout_state_';
 
  class StateManager {
    /**
     * 安全获取 localStorage(某些浏览器/隐私模式下访问会抛错)
     * @returns {Storage|null}
     */
    getStorage() {
      try {
        Iif (!global) return null;
        // 访问 localStorage 属性本身也可能抛错,因此需要 try/catch
        return global.localStorage || null;
      } catch (_) {
        return null;
      }
    }
 
    /**
     * 生成 localStorage key
     * @param {string} userId
     * @returns {string}
     */
    getStorageKey(userId) {
      const safeUserId = userId && String(userId).trim() ? String(userId).trim() : 'guest';
      return `${STORAGE_KEY_PREFIX}${safeUserId}`;
    }
 
    /**
     * 保存状态到 localStorage
     * @param {string} userId
     * @param {Record<string, any>} state
     * @returns {boolean} 是否保存成功
     */
    save(userId, state) {
      const storage = this.getStorage();
      if (!storage) {
        try {
          // eslint-disable-next-line no-console
          console.warn('[StateManager] localStorage not available, using default layout');
        } catch (_) {
          // ignore
        }
        return false;
      }
 
      const payload = {
        ...state,
        version: state && state.version ? state.version : CURRENT_VERSION,
        userId: state && state.userId ? state.userId : userId,
        timestamp: Date.now()
      };
 
      if (!this.validate(payload)) {
        try {
          // eslint-disable-next-line no-console
          console.warn('[StateManager] invalid state, skip saving');
        } catch (_) {
          // ignore
        }
        return false;
      }
 
      const key = this.getStorageKey(userId);
      const json = JSON.stringify(payload);
 
      try {
        storage.setItem(key, json);
        return true;
      } catch (e) {
        // QuotaExceededError:清理旧数据后重试(TDD 7.3)
        if (this.isQuotaExceededError(e)) {
          try {
            // eslint-disable-next-line no-console
            console.warn('[StateManager] quota exceeded, clearing old states and retrying');
          } catch (_) {
            // ignore
          }
          try {
            this.clearOldStates(key);
            storage.setItem(key, json);
            return true;
          } catch (e2) {
            try {
              // eslint-disable-next-line no-console
              console.warn('[StateManager] save retry failed:', e2);
            } catch (_) {
              // ignore
            }
            return false;
          }
        }
 
        // localStorage 不可用等:降级为默认布局
        try {
          // eslint-disable-next-line no-console
          console.warn('[StateManager] save failed:', e);
        } catch (_) {
          // ignore
        }
        return false;
      }
    }
 
    /**
     * 从 localStorage 加载状态
     * @param {string} userId
     * @returns {Record<string, any>|null}
     */
    load(userId) {
      const storage = this.getStorage();
      Iif (!storage) {
        try {
          // eslint-disable-next-line no-console
          console.warn('[StateManager] localStorage not available, using default layout');
        } catch (_) {
          // ignore
        }
        return null;
      }
 
      const key = this.getStorageKey(userId);
 
      let raw = null;
      try {
        raw = storage.getItem(key);
      } catch (e) {
        try {
          // eslint-disable-next-line no-console
          console.warn('[StateManager] load failed (storage error):', e);
        } catch (_) {
          // ignore
        }
        return null;
      }
 
      if (!raw) return null;
 
      let parsed = null;
      try {
        parsed = JSON.parse(raw);
      } catch (e) {
        // JSON 损坏:清除并降级为默认布局(TDD 7.3)
        try {
          this.clear(userId);
        } catch (_) {
          // ignore
        }
        try {
          // eslint-disable-next-line no-console
          console.error('[StateManager] load failed (invalid json):', e);
        } catch (_) {
          // ignore
        }
        return null;
      }
 
      const migrated = this.migrate(parsed);
      if (!this.validate(migrated)) {
        try {
          this.clear(userId);
        } catch (_) {
          // ignore
        }
        try {
          // eslint-disable-next-line no-console
          console.warn('[StateManager] invalid state, cleared and fallback to default');
        } catch (_) {
          // ignore
        }
        return null;
      }
 
      return migrated;
    }
 
    /**
     * 清除保存的状态
     * @param {string} userId
     * @returns {void}
     */
    clear(userId) {
      try {
        const storage = this.getStorage();
        Iif (!storage) return;
        const key = this.getStorageKey(userId);
        storage.removeItem(key);
      } catch (_) {
        // ignore
      }
    }
 
    /**
     * 判断是否为 QuotaExceededError(不同浏览器实现不同)
     * @param {any} error
     * @returns {boolean}
     */
    isQuotaExceededError(error) {
      if (!error) return false;
      const name = typeof error.name === 'string' ? error.name : '';
      if (name === 'QuotaExceededError') return true;
      if (name === 'NS_ERROR_DOM_QUOTA_REACHED') return true;
      const code = typeof error.code === 'number' ? error.code : null;
      // 22/1014:常见的 quota 相关 code(Safari/Firefox)
      return code === 22 || code === 1014;
    }
 
    /**
     * 清理旧的布局状态(用于 QuotaExceededError 降级处理)
     * @param {string} excludeKey 当前写入 key(避免误删正在保存的 key)
     * @returns {void}
     */
    clearOldStates(excludeKey) {
      const storage = this.getStorage();
      Iif (!storage) return;
 
      const keys = [];
      try {
        for (let i = 0; i < storage.length; i += 1) {
          const k = storage.key(i);
          Iif (!k) continue;
          if (!k.startsWith(STORAGE_KEY_PREFIX)) continue;
          if (excludeKey && k === excludeKey) continue;
          keys.push(k);
        }
      } catch (_) {
        // ignore
      }
 
      // 按 timestamp 由旧到新清理(尽量保留最近的偏好)
      const items = keys.map((k) => {
        let ts = 0;
        try {
          const raw = storage.getItem(k);
          Eif (raw) {
            const parsed = JSON.parse(raw);
            ts = parsed && typeof parsed.timestamp === 'number' ? parsed.timestamp : 0;
          }
        } catch (_) {
          ts = 0;
        }
        return { key: k, ts };
      });
 
      items.sort((a, b) => a.ts - b.ts);
 
      items.forEach((item) => {
        try {
          storage.removeItem(item.key);
        } catch (_) {
          // ignore
        }
      });
    }
 
    /**
     * 校验状态数据格式
     * @param {Record<string, any>} state
     * @returns {boolean}
     */
    validate(state) {
      if (!state || typeof state !== 'object') return false;
      if (!state.version || !state.panels) return false;
 
      const panels = state.panels;
      for (const panelType of ['groups', 'accounts', 'emails']) {
        const panel = panels[panelType];
        Iif (!panel || typeof panel !== 'object') return false;
        if (!panel.width || !/^\d+px$/.test(panel.width)) return false;
        if (typeof panel.collapsed !== 'boolean') return false;
      }
 
      return true;
    }
 
    /**
     * 版本迁移(当前仅保证字段齐全;更复杂迁移在后续版本扩展)
     * @param {Record<string, any>} state
     * @returns {Record<string, any>}
     */
    migrate(state) {
      Iif (!state || typeof state !== 'object') return state;
 
      // 若缺少 version,认为是旧数据,补齐为当前版本
      if (!state.version) {
        return { ...state, version: CURRENT_VERSION };
      }
 
      // 已是当前版本则直接返回
      if (state.version === CURRENT_VERSION) return state;
 
      // 其他版本:暂做“兼容性兜底”,保留原结构并标记为当前版本
      return { ...state, version: CURRENT_VERSION };
    }
  }
 
  // 浏览器环境挂载
  global.StateManager = StateManager;
 
  // Jest/Node 环境导出
  Eif (typeof module !== 'undefined' && module.exports) {
    module.exports = StateManager;
  }
})(typeof window !== 'undefined' ? window : globalThis);