preferences.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
  2. import { defaultPreferences } from '../src/config';
  3. import { isDarkTheme } from '../src/update-css-variables';
  4. describe('preferences', () => {
  5. let PreferenceManager: typeof import('../src/preferences').PreferenceManager;
  6. let preferenceManager: InstanceType<
  7. typeof import('../src/preferences').PreferenceManager
  8. >;
  9. // 模拟 window.matchMedia 方法
  10. vi.stubGlobal(
  11. 'matchMedia',
  12. vi.fn().mockImplementation((query) => ({
  13. addEventListener: vi.fn(),
  14. addListener: vi.fn(), // Deprecated
  15. dispatchEvent: vi.fn(),
  16. matches: query === '(prefers-color-scheme: dark)',
  17. media: query,
  18. onchange: null,
  19. removeEventListener: vi.fn(),
  20. removeListener: vi.fn(), // Deprecated
  21. })),
  22. );
  23. vi.stubGlobal('localStorage', {
  24. clear: vi.fn(),
  25. getItem: vi.fn(() => null),
  26. key: vi.fn(() => null),
  27. length: 0,
  28. removeItem: vi.fn(),
  29. setItem: vi.fn(),
  30. });
  31. vi.stubGlobal('sessionStorage', {
  32. clear: vi.fn(),
  33. getItem: vi.fn(() => null),
  34. key: vi.fn(() => null),
  35. length: 0,
  36. removeItem: vi.fn(),
  37. setItem: vi.fn(),
  38. });
  39. beforeAll(async () => {
  40. ({ PreferenceManager } = await import('../src/preferences'));
  41. });
  42. beforeEach(() => {
  43. vi.mocked(localStorage.getItem).mockImplementation(() => null);
  44. vi.mocked(localStorage.removeItem).mockReset();
  45. vi.mocked(localStorage.setItem).mockReset();
  46. vi.mocked(sessionStorage.getItem).mockImplementation(() => null);
  47. vi.mocked(sessionStorage.removeItem).mockReset();
  48. vi.mocked(sessionStorage.setItem).mockReset();
  49. preferenceManager = new PreferenceManager();
  50. });
  51. it('loads default preferences if no saved preferences found', () => {
  52. const preferences = preferenceManager.getPreferences();
  53. expect(preferences).toEqual(defaultPreferences);
  54. });
  55. it('initializes preferences with overrides', async () => {
  56. const overrides: any = {
  57. app: {
  58. locale: 'en-US',
  59. },
  60. };
  61. await preferenceManager.initPreferences({
  62. namespace: 'testNamespace',
  63. overrides,
  64. });
  65. // 等待防抖动操作完成
  66. // await new Promise((resolve) => setTimeout(resolve, 300)); // 等待100毫秒
  67. const expected = {
  68. ...defaultPreferences,
  69. app: {
  70. ...defaultPreferences.app,
  71. ...overrides.app,
  72. },
  73. };
  74. expect(preferenceManager.getPreferences()).toEqual(expected);
  75. });
  76. it('updates theme mode correctly', () => {
  77. preferenceManager.updatePreferences({
  78. theme: {
  79. mode: 'light',
  80. },
  81. });
  82. expect(preferenceManager.getPreferences().theme.mode).toBe('light');
  83. });
  84. it('updates color modes correctly', () => {
  85. preferenceManager.updatePreferences({
  86. app: { colorGrayMode: true, colorWeakMode: true },
  87. });
  88. expect(preferenceManager.getPreferences().app.colorGrayMode).toBe(true);
  89. expect(preferenceManager.getPreferences().app.colorWeakMode).toBe(true);
  90. });
  91. it('resets preferences to default', () => {
  92. // 先更新一些偏好设置
  93. preferenceManager.updatePreferences({
  94. theme: {
  95. mode: 'light',
  96. },
  97. });
  98. // 然后重置偏好设置
  99. preferenceManager.resetPreferences();
  100. expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
  101. });
  102. it('updates isMobile correctly', () => {
  103. // 模拟移动端状态
  104. vi.stubGlobal(
  105. 'matchMedia',
  106. vi.fn().mockImplementation((query) => ({
  107. addEventListener: vi.fn(),
  108. addListener: vi.fn(),
  109. dispatchEvent: vi.fn(),
  110. matches: query === '(max-width: 768px)',
  111. media: query,
  112. onchange: null,
  113. removeEventListener: vi.fn(),
  114. removeListener: vi.fn(),
  115. })),
  116. );
  117. preferenceManager.updatePreferences({
  118. app: { isMobile: true },
  119. });
  120. expect(preferenceManager.getPreferences().app.isMobile).toBe(true);
  121. });
  122. it('updates the locale preference correctly', () => {
  123. preferenceManager.updatePreferences({
  124. app: { locale: 'en-US' },
  125. });
  126. expect(preferenceManager.getPreferences().app.locale).toBe('en-US');
  127. });
  128. it('updates the sidebar width correctly', () => {
  129. preferenceManager.updatePreferences({
  130. sidebar: { width: 200 },
  131. });
  132. expect(preferenceManager.getPreferences().sidebar.width).toBe(200);
  133. });
  134. it('updates the sidebar collapse state correctly', () => {
  135. preferenceManager.updatePreferences({
  136. sidebar: { collapsed: true },
  137. });
  138. expect(preferenceManager.getPreferences().sidebar.collapsed).toBe(true);
  139. });
  140. it('updates the navigation style type correctly', () => {
  141. preferenceManager.updatePreferences({
  142. navigation: { styleType: 'flat' },
  143. } as any);
  144. expect(preferenceManager.getPreferences().navigation.styleType).toBe(
  145. 'flat',
  146. );
  147. });
  148. it('resets preferences to default correctly', () => {
  149. // 先更新一些偏好设置
  150. preferenceManager.updatePreferences({
  151. app: { locale: 'en-US' },
  152. sidebar: { collapsed: true, width: 200 },
  153. theme: {
  154. mode: 'light',
  155. },
  156. });
  157. // 然后重置偏好设置
  158. preferenceManager.resetPreferences();
  159. expect(preferenceManager.getPreferences()).toEqual(defaultPreferences);
  160. });
  161. it('does not update undefined preferences', () => {
  162. const originalPreferences = preferenceManager.getPreferences();
  163. preferenceManager.updatePreferences({
  164. app: { nonexistentField: 'value' },
  165. } as any);
  166. expect(preferenceManager.getPreferences()).toEqual(originalPreferences);
  167. });
  168. it('reverts to default when a preference field is deleted', () => {
  169. preferenceManager.updatePreferences({
  170. app: { locale: 'en-US' },
  171. });
  172. preferenceManager.updatePreferences({
  173. app: { locale: undefined },
  174. });
  175. expect(preferenceManager.getPreferences().app.locale).toBe('en-US');
  176. });
  177. it('ignores updates with invalid preference value types', () => {
  178. const originalPreferences = preferenceManager.getPreferences();
  179. preferenceManager.updatePreferences({
  180. app: { isMobile: 'true' as unknown as boolean }, // 错误类型
  181. });
  182. expect(preferenceManager.getPreferences()).toEqual(originalPreferences);
  183. });
  184. it('merges nested preference objects correctly', () => {
  185. preferenceManager.updatePreferences({
  186. app: { name: 'New App Name' },
  187. });
  188. const expected = {
  189. ...defaultPreferences,
  190. app: {
  191. ...defaultPreferences.app,
  192. name: 'New App Name',
  193. },
  194. };
  195. expect(preferenceManager.getPreferences()).toEqual(expected);
  196. });
  197. it('applies updates immediately after initialization', async () => {
  198. const overrides: any = {
  199. app: {
  200. locale: 'en-US',
  201. },
  202. };
  203. await preferenceManager.initPreferences({
  204. namespace: 'apply-updates',
  205. overrides,
  206. });
  207. preferenceManager.updatePreferences({
  208. theme: { mode: 'light' },
  209. });
  210. expect(preferenceManager.getPreferences().theme.mode).toBe('light');
  211. });
  212. it('initializes custom preferences extension with default values', async () => {
  213. const extension = {
  214. fields: [
  215. {
  216. component: 'switch',
  217. defaultValue: true,
  218. key: 'enableWorkbench',
  219. label: '启用工作台',
  220. },
  221. {
  222. component: 'select',
  223. defaultValue: 'single',
  224. key: 'tenantMode',
  225. label: '租户模式',
  226. options: [
  227. { label: '单租户', value: 'single' },
  228. { label: '多租户', value: 'multi' },
  229. ],
  230. },
  231. ],
  232. tabLabel: '扩展',
  233. title: '业务偏好',
  234. } as const;
  235. await preferenceManager.initPreferences({
  236. extension,
  237. namespace: 'custom-defaults',
  238. });
  239. expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
  240. expect(preferenceManager.getCustomPreferences()).toEqual({
  241. enableWorkbench: true,
  242. tenantMode: 'single',
  243. });
  244. });
  245. it('does not expose mutable custom preference baselines or extension schema', async () => {
  246. const extension = {
  247. fields: [
  248. {
  249. component: 'number',
  250. componentProps: {
  251. max: 10,
  252. min: 2,
  253. step: 2,
  254. },
  255. defaultValue: 4,
  256. key: 'pageSize',
  257. label: '分页大小',
  258. },
  259. ],
  260. tabLabel: '扩展',
  261. title: '业务偏好',
  262. } as const;
  263. await preferenceManager.initPreferences({
  264. extension,
  265. namespace: 'custom-readonly',
  266. });
  267. const initialCustomPreferences =
  268. preferenceManager.getInitialCustomPreferences<{
  269. pageSize: number;
  270. }>() as { pageSize: number };
  271. const preferencesExtension = preferenceManager.getPreferencesExtension<{
  272. pageSize: number;
  273. }>() as {
  274. fields: Array<{ componentProps?: { max?: number }; label: string }>;
  275. };
  276. const [firstField] = preferencesExtension.fields;
  277. initialCustomPreferences.pageSize = 8;
  278. expect(firstField).toBeDefined();
  279. expect(firstField?.componentProps).toBeDefined();
  280. if (!firstField || !firstField.componentProps) {
  281. return;
  282. }
  283. firstField.label = '已修改';
  284. firstField.componentProps.max = 20;
  285. expect(preferenceManager.getInitialCustomPreferences()).toEqual({
  286. pageSize: 4,
  287. });
  288. expect(preferenceManager.getPreferencesExtension()).toEqual(extension);
  289. });
  290. it('updates and resets custom preferences correctly', async () => {
  291. await preferenceManager.initPreferences({
  292. extension: {
  293. fields: [
  294. {
  295. component: 'number',
  296. defaultValue: 20,
  297. key: 'pageSize',
  298. label: '分页大小',
  299. },
  300. {
  301. component: 'input',
  302. defaultValue: '日报',
  303. key: 'reportTitle',
  304. label: '报表标题',
  305. },
  306. ],
  307. tabLabel: '扩展',
  308. },
  309. namespace: 'custom-reset',
  310. });
  311. preferenceManager.updateCustomPreferences({
  312. pageSize: 50,
  313. reportTitle: '月报',
  314. });
  315. expect(preferenceManager.getCustomPreferences()).toEqual({
  316. pageSize: 50,
  317. reportTitle: '月报',
  318. });
  319. preferenceManager.resetPreferences();
  320. expect(preferenceManager.getCustomPreferences()).toEqual({
  321. pageSize: 20,
  322. reportTitle: '日报',
  323. });
  324. });
  325. it('ignores invalid custom preferences updates', async () => {
  326. await preferenceManager.initPreferences({
  327. extension: {
  328. fields: [
  329. {
  330. component: 'switch',
  331. defaultValue: true,
  332. key: 'enableWorkbench',
  333. label: '启用工作台',
  334. },
  335. {
  336. component: 'select',
  337. defaultValue: 'single',
  338. key: 'tenantMode',
  339. label: '租户模式',
  340. options: [
  341. { label: '单租户', value: 'single' },
  342. { label: '多租户', value: 'multi' },
  343. ],
  344. },
  345. ],
  346. tabLabel: '扩展',
  347. },
  348. namespace: 'custom-invalid',
  349. });
  350. const originalCustomPreferences = preferenceManager.getCustomPreferences();
  351. preferenceManager.updateCustomPreferences({
  352. enableWorkbench: 'true' as unknown as boolean,
  353. tenantMode: 'unknown',
  354. unknownField: 'value',
  355. } as any);
  356. expect(preferenceManager.getCustomPreferences()).toEqual(
  357. originalCustomPreferences,
  358. );
  359. });
  360. it('enforces custom number field min max and step constraints', async () => {
  361. await preferenceManager.initPreferences({
  362. extension: {
  363. fields: [
  364. {
  365. component: 'number',
  366. componentProps: {
  367. max: 10,
  368. min: 2,
  369. step: 2,
  370. },
  371. defaultValue: 4,
  372. key: 'pageSize',
  373. label: '分页大小',
  374. },
  375. ],
  376. tabLabel: '扩展',
  377. },
  378. namespace: 'custom-number-constraints',
  379. });
  380. preferenceManager.updateCustomPreferences({
  381. pageSize: 8,
  382. });
  383. expect(preferenceManager.getCustomPreferences()).toEqual({
  384. pageSize: 8,
  385. });
  386. preferenceManager.updateCustomPreferences({
  387. pageSize: 1,
  388. });
  389. expect(preferenceManager.getCustomPreferences()).toEqual({
  390. pageSize: 8,
  391. });
  392. preferenceManager.updateCustomPreferences({
  393. pageSize: 12,
  394. });
  395. expect(preferenceManager.getCustomPreferences()).toEqual({
  396. pageSize: 8,
  397. });
  398. preferenceManager.updateCustomPreferences({
  399. pageSize: 5,
  400. });
  401. expect(preferenceManager.getCustomPreferences()).toEqual({
  402. pageSize: 8,
  403. });
  404. });
  405. it('filters cached custom number values that violate field constraints', async () => {
  406. vi.mocked(localStorage.getItem).mockImplementation((key) => {
  407. if (key.endsWith('cache-preferences-custom')) {
  408. return JSON.stringify({
  409. value: {
  410. pageSize: 5,
  411. },
  412. });
  413. }
  414. return null;
  415. });
  416. await preferenceManager.initPreferences({
  417. extension: {
  418. fields: [
  419. {
  420. component: 'number',
  421. componentProps: {
  422. max: 10,
  423. min: 2,
  424. step: 2,
  425. },
  426. defaultValue: 4,
  427. key: 'pageSize',
  428. label: '分页大小',
  429. },
  430. ],
  431. tabLabel: '扩展',
  432. },
  433. namespace: 'custom-number-cache',
  434. });
  435. expect(preferenceManager.getCustomPreferences()).toEqual({
  436. pageSize: 4,
  437. });
  438. });
  439. });
  440. describe('isDarkTheme', () => {
  441. it('should return true for dark theme', () => {
  442. expect(isDarkTheme('dark')).toBe(true);
  443. });
  444. it('should return false for light theme', () => {
  445. expect(isDarkTheme('light')).toBe(false);
  446. });
  447. it('should return system preference for auto theme', () => {
  448. vi.spyOn(window, 'matchMedia').mockImplementation((query) => ({
  449. addEventListener: vi.fn(),
  450. addListener: vi.fn(), // Deprecated
  451. dispatchEvent: vi.fn(),
  452. matches: query === '(prefers-color-scheme: dark)',
  453. media: query,
  454. onchange: null,
  455. removeEventListener: vi.fn(),
  456. removeListener: vi.fn(), // Deprecated
  457. }));
  458. expect(isDarkTheme('auto')).toBe(true);
  459. expect(window.matchMedia).toHaveBeenCalledWith(
  460. '(prefers-color-scheme: dark)',
  461. );
  462. });
  463. });