feat(preferences): integrate PreferenceService and enhance testing capabilities

This commit refactors the PreferenceService to use named exports for better consistency and updates the DbService import accordingly. It introduces a testing mechanism for the PreferenceService by creating test windows to facilitate cross-window preference synchronization testing. Additionally, improvements are made to the usePreference hook for better performance and stability, ensuring efficient preference management in the application.
This commit is contained in:
fullex 2025-08-12 13:44:41 +08:00
parent c02f93e6b9
commit b219e96544
15 changed files with 1412 additions and 25 deletions

View File

@ -1,3 +1,4 @@
import { dbService } from '@data/db/DbService'
import { loggerService } from '@logger'
import { DefaultPreferences } from '@shared/data/preferences'
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types'
@ -5,7 +6,6 @@ import { IpcChannel } from '@shared/IpcChannel'
import { and, eq } from 'drizzle-orm'
import { BrowserWindow } from 'electron'
import dbService from './db/DbService'
import { preferenceTable } from './db/schemas/preference'
const logger = loggerService.withContext('PreferenceService')
@ -296,4 +296,3 @@ export class PreferenceService {
// Export singleton instance
export const preferenceService = PreferenceService.getInstance()
export default preferenceService

View File

@ -67,6 +67,4 @@ class DbService {
}
// Export a singleton instance
const dbService = DbService.getInstance()
export default dbService
export const dbService = DbService.getInstance()

View File

@ -1,4 +1,4 @@
import dbService from '@data/db/DbService'
import { dbService } from '@data/db/DbService'
import { appStateTable } from '@data/db/schemas/appState'
import { loggerService } from '@logger'
import { isDev } from '@main/constant'
@ -48,6 +48,7 @@ interface MigrationResult {
export class DataRefactorMigrateService {
private static instance: DataRefactorMigrateService | null = null
private migrateWindow: BrowserWindow | null = null
private testWindows: BrowserWindow[] = []
private backupManager: BackupManager
private db = dbService.getDb()
private currentProgress: MigrationProgress = {
@ -302,6 +303,14 @@ export class DataRefactorMigrateService {
return DataRefactorMigrateService.instance
}
/**
* Convenient static method to open test window
*/
public static openTestWindow(): BrowserWindow {
const instance = DataRefactorMigrateService.getInstance()
return instance.createTestWindow()
}
/**
* Check if migration is needed
*/
@ -862,6 +871,112 @@ export class DataRefactorMigrateService {
// Don't throw - still allow restart
}
}
/**
* Create and show test window for testing PreferenceService and usePreference functionality
*/
public createTestWindow(): BrowserWindow {
const windowNumber = this.testWindows.length + 1
const testWindow = new BrowserWindow({
width: 1000,
height: 700,
minWidth: 800,
minHeight: 600,
resizable: true,
maximizable: true,
minimizable: true,
show: false,
frame: true,
autoHideMenuBar: true,
title: `Data Refactor Test Window #${windowNumber} - PreferenceService Testing`,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
webSecurity: false,
contextIsolation: true
}
})
// Add to test windows array
this.testWindows.push(testWindow)
// Load the test window
if (isDev && process.env['ELECTRON_RENDERER_URL']) {
testWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/dataRefactorTest.html')
// Open DevTools in development mode for easier testing
testWindow.webContents.openDevTools()
} else {
testWindow.loadFile(join(__dirname, '../renderer/dataRefactorTest.html'))
}
testWindow.once('ready-to-show', () => {
testWindow?.show()
testWindow?.focus()
})
testWindow.on('closed', () => {
// Remove from test windows array when closed
const index = this.testWindows.indexOf(testWindow)
if (index > -1) {
this.testWindows.splice(index, 1)
}
})
logger.info(`Test window #${windowNumber} created for PreferenceService testing`)
return testWindow
}
/**
* Get test window instance (first one)
*/
public getTestWindow(): BrowserWindow | null {
return this.testWindows.length > 0 ? this.testWindows[0] : null
}
/**
* Get all test windows
*/
public getTestWindows(): BrowserWindow[] {
return this.testWindows.filter((window) => !window.isDestroyed())
}
/**
* Close all test windows
*/
public closeTestWindows(): void {
this.testWindows.forEach((window) => {
if (!window.isDestroyed()) {
window.close()
}
})
this.testWindows = []
logger.info('All test windows closed')
}
/**
* Close a specific test window
*/
public closeTestWindow(window?: BrowserWindow): void {
if (window) {
if (!window.isDestroyed()) {
window.close()
}
} else {
// Close first window if no specific window provided
const firstWindow = this.getTestWindow()
if (firstWindow && !firstWindow.isDestroyed()) {
firstWindow.close()
}
}
}
/**
* Check if any test windows are open
*/
public isTestWindowOpen(): boolean {
return this.testWindows.some((window) => !window.isDestroyed())
}
}
// Export singleton instance

View File

@ -1,4 +1,4 @@
import dbService from '@data/db/DbService'
import { dbService } from '@data/db/DbService'
import { preferenceTable } from '@data/db/schemas/preference'
import { loggerService } from '@logger'
import { DefaultPreferences } from '@shared/data/preferences'

View File

@ -7,7 +7,8 @@ import '@main/config'
import { loggerService } from '@logger'
import { electronApp, optimizer } from '@electron-toolkit/utils'
import dbService from '@data/db/DbService'
import { dbService } from '@data/db/DbService'
import { preferenceService } from '@data/PreferenceService'
import { replaceDevtoolsFont } from '@main/utils/windowUtil'
import { app, dialog } from 'electron'
import installExtension, { REACT_DEVELOPER_TOOLS, REDUX_DEVTOOLS } from 'electron-devtools-installer'
@ -154,6 +155,22 @@ if (!app.requestSingleInstanceLock()) {
return
}
//************FOR TESTING ONLY START****************/
await preferenceService.initialize()
// Create two test windows for cross-window preference sync testing
logger.info('Creating test windows for PreferenceService cross-window sync testing')
const testWindow1 = dataRefactorMigrateService.createTestWindow()
const testWindow2 = dataRefactorMigrateService.createTestWindow()
// Position windows to avoid overlap
testWindow1.once('ready-to-show', () => {
const [x, y] = testWindow1.getPosition()
testWindow2.setPosition(x + 50, y + 50)
})
/************FOR TESTING ONLY END****************/
// Set app user model id for windows
electronApp.setAppUserModelId(import.meta.env.VITE_MAIN_BUNDLE_ID || 'com.kangfenmao.CherryStudio')

View File

@ -2,6 +2,7 @@ import fs from 'node:fs'
import { arch } from 'node:os'
import path from 'node:path'
import { preferenceService } from '@data/PreferenceService'
import { loggerService } from '@logger'
import { isLinux, isMac, isPortable, isWin } from '@main/constant'
import { getBinaryPath, isBinaryExists, runInstallScript } from '@main/utils/process'
@ -14,7 +15,6 @@ import { FileMetadata, Provider, Shortcut, ThemeMode } from '@types'
import { BrowserWindow, dialog, ipcMain, ProxyConfig, session, shell, systemPreferences, webContents } from 'electron'
import { Notification } from 'src/renderer/src/types/notification'
import preferenceService from './data/PreferenceService'
import appService from './services/AppService'
import AppUpdater from './services/AppUpdater'
import BackupManager from './services/BackupManager'

View File

@ -0,0 +1,63 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Data Refactor Test Window - PreferenceService Testing</title>
<meta http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:;" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body id="root" theme-mode="light">
<script type="module" src="/src/windows/dataRefactorTest/entryPoint.tsx"></script>
<style>
html {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
#root {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
box-sizing: border-box;
}
/* Custom button styles */
.ant-btn-primary {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.ant-btn-primary:hover {
background-color: var(--color-primary-soft) !important;
border-color: var(--color-primary-soft) !important;
}
.ant-btn-primary:active,
.ant-btn-primary:focus {
background-color: var(--color-primary) !important;
border-color: var(--color-primary) !important;
}
/* Non-primary button hover styles */
.ant-btn:not(.ant-btn-primary):hover {
border-color: var(--color-primary-soft) !important;
color: var(--color-primary) !important;
}
</style>
</body>
</html>

View File

@ -1,6 +1,6 @@
import { loggerService } from '@logger'
import type { PreferenceDefaultScopeType, PreferenceKeyType } from '@shared/data/types'
import { useCallback, useEffect, useMemo, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
import { preferenceService } from '../PreferenceService'
@ -18,8 +18,8 @@ export function usePreference<K extends PreferenceKeyType>(
): [PreferenceDefaultScopeType[K] | undefined, (value: PreferenceDefaultScopeType[K]) => Promise<void>] {
// Subscribe to changes for this specific preference
const value = useSyncExternalStore(
preferenceService.subscribeToKey(key),
preferenceService.getSnapshot(key),
useCallback((callback) => preferenceService.subscribeToKey(key)(callback), [key]),
useCallback(() => preferenceService.getCachedValue(key), [key]),
() => undefined // SSR snapshot (not used in Electron context)
)
@ -61,12 +61,16 @@ export function usePreferences<T extends Record<string, PreferenceKeyType>>(
{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] | undefined },
(updates: Partial<{ [P in keyof T]: PreferenceDefaultScopeType[T[P]] }>) => Promise<void>
] {
// Track changes to any of the specified keys
// Create stable key dependencies
const keyList = useMemo(() => Object.values(keys), [keys])
const keyListString = keyList.join(',')
const keysStringified = useMemo(() => JSON.stringify(keys), [keys])
// Cache the last snapshot to avoid infinite loops
const lastSnapshotRef = useRef<Record<string, any>>({})
const allValues = useSyncExternalStore(
useCallback(
(callback) => {
(callback: () => void) => {
// Subscribe to all keys and aggregate the unsubscribe functions
const unsubscribeFunctions = keyList.map((key) => preferenceService.subscribeToKey(key)(callback))
@ -74,17 +78,30 @@ export function usePreferences<T extends Record<string, PreferenceKeyType>>(
unsubscribeFunctions.forEach((unsubscribe) => unsubscribe())
}
},
[keyList]
[keysStringified]
),
useCallback(() => {
// Return current snapshot of all values
const snapshot: Record<string, any> = {}
// Check if any values have actually changed
let hasChanged = Object.keys(lastSnapshotRef.current).length === 0 // First time
const newSnapshot: Record<string, any> = {}
for (const [localKey, prefKey] of Object.entries(keys)) {
snapshot[localKey] = preferenceService.getCachedValue(prefKey)
const currentValue = preferenceService.getCachedValue(prefKey)
newSnapshot[localKey] = currentValue
if (!hasChanged && lastSnapshotRef.current[localKey] !== currentValue) {
hasChanged = true
}
}
return snapshot
}, [keys]),
// Only create new object if data actually changed
if (hasChanged) {
lastSnapshotRef.current = newSnapshot
}
return lastSnapshotRef.current
}, [keysStringified]),
() => ({}) // SSR snapshot
)
@ -98,7 +115,7 @@ export function usePreferences<T extends Record<string, PreferenceKeyType>>(
logger.error('Failed to load initial preferences:', error as Error)
})
}
}, [keyList, keyListString])
}, [keysStringified])
// Memoized batch update function
const updateValues = useCallback(
@ -119,7 +136,7 @@ export function usePreferences<T extends Record<string, PreferenceKeyType>>(
throw error
}
},
[keys]
[keysStringified]
)
// Type-cast the values to the expected shape
@ -135,12 +152,12 @@ export function usePreferences<T extends Record<string, PreferenceKeyType>>(
* @param keys - Array of preference keys to preload
*/
export function usePreferencePreload(keys: PreferenceKeyType[]): void {
const keysString = keys.join(',')
const keysString = useMemo(() => keys.join(','), [keys])
useEffect(() => {
preferenceService.preload(keys).catch((error) => {
logger.error('Failed to preload preferences:', error as Error)
})
}, [keys, keysString])
}, [keysString])
}
/**

View File

@ -0,0 +1,118 @@
# PreferenceService 测试窗口
专用于测试 PreferenceService 和 usePreference hooks 功能的独立测试窗口系统。
## 🎯 当前实现
**已完成的功能**
- 专用的测试窗口 (DataRefactorTestWindow)
- **双窗口启动**:应用启动时会同时打开主窗口和两个测试窗口
- **跨窗口同步测试**:两个测试窗口可以相互验证偏好设置的实时同步
- 完整的测试界面包含4个专业测试组件
- 自动窗口定位,避免重叠
- 窗口编号标识,便于区分
## 测试组件
### 1. PreferenceService 基础测试
- 直接测试服务层APIget, set, getCachedValue, isCached, preload, getMultiple
- 支持各种数据类型字符串、数字、布尔值、JSON对象
- 实时显示操作结果
### 2. usePreference Hook 测试
- 测试单个偏好设置的React hooks
- 支持的测试键:
- `app.theme.mode` - 主题模式
- `app.language` - 语言设置
- `app.spell_check.enabled` - 拼写检查
- `app.zoom_factor` - 缩放因子
- 实时值更新和类型转换
### 3. usePreferences 批量操作测试
- 测试多个偏好设置的批量管理
- 4种预设场景基础设置、UI设置、用户设置、自定义组合
- 批量更新功能支持JSON格式输入
- 快速切换操作
### 4. Hook 高级功能测试
- 预加载机制测试
- 订阅机制验证
- 缓存管理测试
- 性能测试
- 多个hook实例同步测试
## 启动方式
**自动启动**:应用正常启动时会自动创建两个测试窗口,窗口会自动错位显示避免重叠
**手动启动**
```javascript
// 在开发者控制台中执行 - 创建单个测试窗口
const { dataRefactorMigrateService } = require('./out/main/data/migrate/dataRefactor/DataRefactorMigrateService')
dataRefactorMigrateService.createTestWindow()
// 创建多个测试窗口
dataRefactorMigrateService.createTestWindow() // 第二个窗口
dataRefactorMigrateService.createTestWindow() // 第三个窗口...
// 关闭所有测试窗口
dataRefactorMigrateService.closeTestWindows()
```
## 文件结构
```
src/renderer/src/windows/dataRefactorTest/
├── entryPoint.tsx # 窗口入口
├── TestApp.tsx # 主应用组件
└── components/
├── PreferenceServiceTests.tsx # 服务层测试
├── PreferenceBasicTests.tsx # 基础Hook测试
├── PreferenceHookTests.tsx # 高级Hook测试
└── PreferenceMultipleTests.tsx # 批量操作测试
```
## 跨窗口同步测试
🔄 **测试场景**
1. **实时同步验证**:在窗口#1中修改某个偏好设置立即观察窗口#2是否同步更新
2. **并发修改测试**:在两个窗口中快速连续修改同一设置,验证数据一致性
3. **批量操作同步**:在一个窗口中批量更新多个设置,观察另一个窗口的同步表现
4. **Hook实例同步**验证多个usePreference hook实例是否正确同步
📋 **测试步骤**
1. 同时打开两个测试窗口(自动启动)
2. 选择相同的偏好设置键进行测试
3. 在窗口#1中修改值观察窗口#2的反应
4. 检查"Hook 高级功能测试"中的订阅触发次数是否增加
5. 验证缓存状态和实时数据的一致性
## 注意事项
⚠️ **所有测试操作都会影响真实的数据库存储!**
- 测试使用真实的偏好设置系统
- 修改的值会同步到主应用和所有测试窗口
- 可以在主应用、测试窗口#1、测试窗口#2之间看到实时同步效果
## 开发模式特性
- 自动打开DevTools便于调试
- 支持热重载
- 完整的TypeScript类型检查
- React DevTools支持
## 💡 快速开始
1. **启动应用** - 自动打开2个测试窗口
2. **选择测试** - 在"usePreference Hook 测试"中选择要测试的偏好设置键
3. **跨窗口验证** - 在窗口#1中修改值观察窗口#2是否同步
4. **高级测试** - 使用"Hook 高级功能测试"验证订阅和缓存机制
5. **批量测试** - 使用"usePreferences 批量操作测试"进行多项设置测试
## 🔧 技术实现
- **窗口管理**: DataRefactorMigrateService 单例管理多个测试窗口
- **数据同步**: 基于真实的 PreferenceService 和 IPC 通信
- **UI框架**: Ant Design + styled-components + React 18
- **类型安全**: 完整的 TypeScript 类型检查和偏好设置键约束

View File

@ -0,0 +1,153 @@
import { AppLogo } from '@renderer/config/env'
import { loggerService } from '@renderer/services/LoggerService'
import { Button, Card, Col, Divider, Layout, Row, Space, Typography } from 'antd'
import { Database, FlaskConical, Settings, TestTube } from 'lucide-react'
import React from 'react'
import styled from 'styled-components'
import PreferenceBasicTests from './components/PreferenceBasicTests'
import PreferenceHookTests from './components/PreferenceHookTests'
import PreferenceMultipleTests from './components/PreferenceMultipleTests'
import PreferenceServiceTests from './components/PreferenceServiceTests'
const { Header, Content } = Layout
const { Title, Text } = Typography
const logger = loggerService.withContext('TestApp')
const TestApp: React.FC = () => {
// Get window title to determine window number
const windowTitle = document.title
const windowMatch = windowTitle.match(/#(\d+)/)
const windowNumber = windowMatch ? windowMatch[1] : '1'
return (
<Layout style={{ height: '100vh' }}>
<Header style={{ background: '#fff', borderBottom: '1px solid #f0f0f0', padding: '0 24px' }}>
<HeaderContent>
<Space align="center">
<img src={AppLogo} alt="Logo" style={{ width: 28, height: 28, borderRadius: 6 }} />
<Title level={4} style={{ margin: 0, color: 'var(--color-primary)' }}>
Test Window #{windowNumber}
</Title>
</Space>
<Space>
<FlaskConical size={20} color="var(--color-primary)" />
<Text type="secondary">Cross-Window Sync Testing</Text>
</Space>
</HeaderContent>
</Header>
<Content style={{ padding: '24px', overflow: 'auto' }}>
<Container>
<Row gutter={[24, 24]}>
{/* Introduction Card */}
<Col span={24}>
<Card>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space align="center">
<TestTube size={24} color="var(--color-primary)" />
<Title level={3} style={{ margin: 0 }}>
PreferenceService ( #{windowNumber})
</Title>
</Space>
<Text type="secondary">
PreferenceService usePreference hooks
</Text>
<Text type="secondary">使</Text>
<Text type="secondary" style={{ color: 'var(--color-primary)', fontWeight: 'bold' }}>
📋
</Text>
</Space>
</Card>
</Col>
{/* PreferenceService Basic Tests */}
<Col span={24}>
<Card
title={
<Space>
<Database size={18} />
<span>PreferenceService </span>
</Space>
}
size="small">
<PreferenceServiceTests />
</Card>
</Col>
{/* Basic Hook Tests */}
<Col span={12}>
<Card
title={
<Space>
<Settings size={18} />
<span>usePreference Hook </span>
</Space>
}
size="small">
<PreferenceBasicTests />
</Card>
</Col>
{/* Hook Tests */}
<Col span={12}>
<Card
title={
<Space>
<Settings size={18} />
<span>Hook </span>
</Space>
}
size="small">
<PreferenceHookTests />
</Card>
</Col>
{/* Multiple Preferences Tests */}
<Col span={24}>
<Card
title={
<Space>
<Database size={18} />
<span>usePreferences </span>
</Space>
}
size="small">
<PreferenceMultipleTests />
</Card>
</Col>
</Row>
<Divider />
<Row justify="center">
<Button
type="primary"
onClick={() => {
logger.info('Closing test window')
window.close()
}}>
</Button>
</Row>
</Container>
</Content>
</Layout>
)
}
const HeaderContent = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
`
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
`
export default TestApp

View File

@ -0,0 +1,213 @@
import { usePreference } from '@renderer/data/hooks/usePreference'
import type { PreferenceKeyType } from '@shared/data/types'
import { Button, Input, message, Select, Space, Switch, Typography } from 'antd'
import React, { useState } from 'react'
import styled from 'styled-components'
const { Text } = Typography
const { Option } = Select
/**
* Basic usePreference hook testing component
* Tests single preference management with React hooks
*/
const PreferenceBasicTests: React.FC = () => {
const [selectedKey, setSelectedKey] = useState<PreferenceKeyType>('app.theme.mode')
// Use the hook with the selected key
const [value, setValue] = usePreference(selectedKey)
const [inputValue, setInputValue] = useState<string>('')
const handleSetValue = async () => {
try {
let parsedValue: any = inputValue
// Try to parse as JSON if it looks like an object/array/boolean/number
if (
inputValue.startsWith('{') ||
inputValue.startsWith('[') ||
inputValue === 'true' ||
inputValue === 'false' ||
!isNaN(Number(inputValue))
) {
try {
parsedValue = JSON.parse(inputValue)
} catch {
// Keep as string if JSON parsing fails
}
}
await setValue(parsedValue)
message.success('设置成功')
setInputValue('')
} catch (error) {
message.error(`设置失败: ${(error as Error).message}`)
}
}
const testCases = [
{ key: 'app.theme.mode', label: 'App Theme Mode', sampleValue: 'ThemeMode.dark' },
{ key: 'app.language', label: 'App Language', sampleValue: 'zh-CN' },
{ key: 'app.spell_check.enabled', label: 'Spell Check', sampleValue: 'true' },
{ key: 'app.zoom_factor', label: 'Zoom Factor', sampleValue: '1.2' },
{ key: 'app.tray.enabled', label: 'Tray Enabled', sampleValue: 'true' }
]
return (
<TestContainer>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{/* Key Selection */}
<div>
<Text strong>:</Text>
<Select
value={selectedKey}
onChange={setSelectedKey}
style={{ width: '100%', marginTop: 4 }}
placeholder="选择偏好设置键">
{testCases.map((testCase) => (
<Option key={testCase.key} value={testCase.key}>
{testCase.label} ({testCase.key})
</Option>
))}
</Select>
</div>
{/* Current Value Display */}
<CurrentValueContainer>
<Text strong>:</Text>
<ValueDisplay>
{value !== undefined ? (
typeof value === 'object' ? (
JSON.stringify(value, null, 2)
) : (
String(value)
)
) : (
<Text type="secondary">undefined ()</Text>
)}
</ValueDisplay>
</CurrentValueContainer>
{/* Set New Value */}
<div>
<Text strong>:</Text>
<Space.Compact style={{ width: '100%', marginTop: 4 }}>
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="输入新值 (JSON格式用于对象/数组)"
onPressEnter={handleSetValue}
/>
<Button type="primary" onClick={handleSetValue}>
</Button>
</Space.Compact>
</div>
{/* Quick Actions */}
<div>
<Text strong>:</Text>
<Space wrap style={{ marginTop: 8 }}>
{/* Theme Toggle */}
{selectedKey === 'app.theme.mode' && (
<Button
size="small"
onClick={() => setValue(value === 'ThemeMode.dark' ? 'ThemeMode.light' : 'ThemeMode.dark')}>
({value === 'ThemeMode.dark' ? 'light' : 'dark'})
</Button>
)}
{/* Boolean Toggle */}
{selectedKey === 'app.spell_check.enabled' && (
<Switch
checked={value === true}
onChange={(checked) => setValue(checked)}
checkedChildren="启用"
unCheckedChildren="禁用"
/>
)}
{/* Language Switch */}
{selectedKey === 'app.language' && (
<>
<Button size="small" onClick={() => setValue('zh-CN')}>
</Button>
<Button size="small" onClick={() => setValue('en-US')}>
English
</Button>
</>
)}
{/* Zoom Factor */}
{selectedKey === 'app.zoom_factor' && (
<>
<Button size="small" onClick={() => setValue(0.8)}>
80%
</Button>
<Button size="small" onClick={() => setValue(1.0)}>
100%
</Button>
<Button size="small" onClick={() => setValue(1.2)}>
120%
</Button>
</>
)}
{/* Sample Values */}
<Button
size="small"
type="dashed"
onClick={() => {
const testCase = testCases.find((tc) => tc.key === selectedKey)
if (testCase) {
setInputValue(testCase.sampleValue)
}
}}>
</Button>
</Space>
</div>
{/* Hook Status Info */}
<StatusContainer>
<Text type="secondary" style={{ fontSize: '12px' }}>
Hook状态: 当前监听 "{selectedKey}", : {typeof value}, : {value !== undefined ? '是' : '否'}
</Text>
</StatusContainer>
</Space>
</TestContainer>
)
}
const TestContainer = styled.div`
padding: 16px;
background: #fafafa;
border-radius: 8px;
`
const CurrentValueContainer = styled.div`
padding: 12px;
background: #f0f0f0;
border-radius: 6px;
border-left: 4px solid var(--color-primary);
`
const ValueDisplay = styled.pre`
margin: 8px 0 0 0;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #333;
white-space: pre-wrap;
word-break: break-all;
`
const StatusContainer = styled.div`
padding: 8px;
background: #e6f7ff;
border-radius: 4px;
border: 1px solid #91d5ff;
`
export default PreferenceBasicTests

View File

@ -0,0 +1,177 @@
import { usePreference, usePreferencePreload, usePreferenceService } from '@renderer/data/hooks/usePreference'
import type { PreferenceKeyType } from '@shared/data/types'
import { Button, Card, message, Space, Typography } from 'antd'
import React, { useState } from 'react'
import styled from 'styled-components'
const { Text } = Typography
/**
* Advanced usePreference hook testing component
* Tests preloading, service access, and hook behavior
*/
const PreferenceHookTests: React.FC = () => {
const preferenceService = usePreferenceService()
const [subscriptionCount, setSubscriptionCount] = useState(0)
// Test multiple hooks with same key
const [theme1] = usePreference('app.theme.mode')
const [theme2] = usePreference('app.theme.mode')
const [language] = usePreference('app.language')
// Preload test
usePreferencePreload(['app.theme.mode', 'app.language', 'app.zoom_factor'])
// Use useRef to track render count without causing re-renders
const renderCountRef = React.useRef(0)
renderCountRef.current += 1
const testSubscriptions = () => {
// Test subscription behavior
const unsubscribe = preferenceService.subscribeToKey('app.theme.mode')(() => {
setSubscriptionCount(prev => prev + 1)
})
message.info('已添加订阅修改app.theme.mode将触发计数')
// Clean up after 10 seconds
setTimeout(() => {
unsubscribe()
message.info('订阅已取消')
}, 10000)
}
const testCacheWarming = async () => {
try {
const keys: PreferenceKeyType[] = ['app.theme.mode', 'app.language', 'app.zoom_factor', 'app.spell_check.enabled']
await preferenceService.preload(keys)
const cachedStates = keys.map(key => ({
key,
isCached: preferenceService.isCached(key),
value: preferenceService.getCachedValue(key)
}))
message.success(`预加载完成。缓存状态: ${cachedStates.filter(s => s.isCached).length}/${keys.length}`)
console.log('Cache states:', cachedStates)
} catch (error) {
message.error(`预加载失败: ${(error as Error).message}`)
}
}
const testBatchOperations = async () => {
try {
const keys: PreferenceKeyType[] = ['app.theme.mode', 'app.language']
const result = await preferenceService.getMultiple(keys)
message.success(`批量获取成功: ${Object.keys(result).length}`)
console.log('Batch get result:', result)
// Test batch set
await preferenceService.setMultiple({
'app.theme.mode': theme1 === 'ThemeMode.dark' ? 'ThemeMode.light' : 'ThemeMode.dark',
'app.language': language === 'zh-CN' ? 'en-US' : 'zh-CN'
})
message.success('批量设置成功')
} catch (error) {
message.error(`批量操作失败: ${(error as Error).message}`)
}
}
const performanceTest = async () => {
const start = performance.now()
const iterations = 100
try {
// Test rapid reads
for (let i = 0; i < iterations; i++) {
preferenceService.getCachedValue('app.theme.mode')
}
const readTime = performance.now() - start
// Test rapid writes
const writeStart = performance.now()
for (let i = 0; i < 10; i++) {
await preferenceService.set('app.theme.mode', `ThemeMode.test_${i}`)
}
const writeTime = performance.now() - writeStart
message.success(`性能测试完成: 读取${iterations}次耗时${readTime.toFixed(2)}ms, 写入10次耗时${writeTime.toFixed(2)}ms`)
} catch (error) {
message.error(`性能测试失败: ${(error as Error).message}`)
}
}
return (
<TestContainer>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{/* Hook State Display */}
<Card size="small" title="Hook 状态监控">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text>: <Text strong>{renderCountRef.current}</Text></Text>
<Text>: <Text strong>{subscriptionCount}</Text></Text>
<Text>Theme Hook 1: <Text code>{String(theme1)}</Text></Text>
<Text>Theme Hook 2: <Text code>{String(theme2)}</Text></Text>
<Text>Language Hook: <Text code>{String(language)}</Text></Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
注意: 相同key的多个hook应该返回相同值
</Text>
</Space>
</Card>
{/* Test Actions */}
<Space wrap>
<Button onClick={testSubscriptions}>
</Button>
<Button onClick={testCacheWarming}>
</Button>
<Button onClick={testBatchOperations}>
</Button>
<Button onClick={performanceTest}>
</Button>
</Space>
{/* Service Information */}
<Card size="small" title="Service 信息">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text>Service实例: <Text code>{preferenceService ? '已连接' : '未连接'}</Text></Text>
<Text>预加载Keys: app.theme.mode, app.language, app.zoom_factor</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
usePreferenceService()
</Text>
</Space>
</Card>
{/* Hook Behavior Tests */}
<Card size="small" title="Hook 行为测试">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text strong>:</Text>
<Text type="secondary">
1. app.theme.mode app.language
</Text>
<Text type="secondary">
2.
</Text>
<Text type="secondary">
3.
</Text>
</Space>
</Card>
</Space>
</TestContainer>
)
}
const TestContainer = styled.div`
padding: 16px;
background: #fafafa;
border-radius: 8px;
`
export default PreferenceHookTests

View File

@ -0,0 +1,286 @@
import { usePreferences } from '@renderer/data/hooks/usePreference'
import { Button, Card, Input, message, Select, Space, Table, Typography } from 'antd'
import { ColumnType } from 'antd/es/table'
import React, { useState } from 'react'
import styled from 'styled-components'
const { Text } = Typography
const { Option } = Select
/**
* usePreferences hook testing component
* Tests multiple preferences management with batch operations
*/
const PreferenceMultipleTests: React.FC = () => {
// Define different test scenarios
const [scenario, setScenario] = useState<string>('basic')
const scenarios = {
basic: {
theme: 'app.theme.mode',
language: 'app.language',
zoom: 'app.zoom_factor'
},
ui: {
theme: 'app.theme.mode',
zoom: 'app.zoom_factor',
spell: 'app.spell_check.enabled'
},
user: {
tray: 'app.tray.enabled',
userName: 'app.user.name',
devMode: 'app.developer_mode.enabled'
},
custom: {
key1: 'app.theme.mode',
key2: 'app.language',
key3: 'app.zoom_factor',
key4: 'app.spell_check.enabled'
}
} as const
const currentKeys = scenarios[scenario as keyof typeof scenarios]
const [values, updateValues] = usePreferences(currentKeys)
const [batchInput, setBatchInput] = useState<string>('')
const handleBatchUpdate = async () => {
try {
const updates = JSON.parse(batchInput)
await updateValues(updates)
message.success('批量更新成功')
setBatchInput('')
} catch (error) {
if (error instanceof SyntaxError) {
message.error('JSON格式错误')
} else {
message.error(`更新失败: ${(error as Error).message}`)
}
}
}
const handleQuickUpdate = async (key: string, value: any) => {
try {
await updateValues({ [key]: value })
message.success(`${key} 更新成功`)
} catch (error) {
message.error(`更新失败: ${(error as Error).message}`)
}
}
const generateSampleBatch = () => {
const sampleUpdates: Record<string, any> = {}
Object.keys(currentKeys).forEach((localKey, index) => {
const prefKey = currentKeys[localKey as keyof typeof currentKeys]
switch (prefKey) {
case 'app.theme.mode':
sampleUpdates[localKey] = values[localKey] === 'ThemeMode.dark' ? 'ThemeMode.light' : 'ThemeMode.dark'
break
case 'app.language':
sampleUpdates[localKey] = values[localKey] === 'zh-CN' ? 'en-US' : 'zh-CN'
break
case 'app.zoom_factor':
sampleUpdates[localKey] = 1.0 + (index * 0.1)
break
case 'app.spell_check.enabled':
sampleUpdates[localKey] = !values[localKey]
break
default:
sampleUpdates[localKey] = `sample_value_${index}`
}
})
setBatchInput(JSON.stringify(sampleUpdates, null, 2))
}
// Table columns for displaying values
const columns: ColumnType<any>[] = [
{
title: '本地键名',
dataIndex: 'localKey',
key: 'localKey',
width: 120
},
{
title: '偏好设置键',
dataIndex: 'prefKey',
key: 'prefKey',
width: 200
},
{
title: '当前值',
dataIndex: 'value',
key: 'value',
render: (value: any) => (
<ValueDisplay>
{value !== undefined ? (
typeof value === 'object' ? JSON.stringify(value) : String(value)
) : (
<Text type="secondary">undefined</Text>
)}
</ValueDisplay>
)
},
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 80,
render: (type: string) => <Text code>{type}</Text>
},
{
title: '操作',
key: 'actions',
width: 150,
render: (_, record) => (
<Space size="small">
{record.prefKey === 'app.theme.mode' && (
<Button size="small" onClick={() => handleQuickUpdate(record.localKey, record.value === 'ThemeMode.dark' ? 'ThemeMode.light' : 'ThemeMode.dark')}>
</Button>
)}
{record.prefKey === 'app.spell_check.enabled' && (
<Button size="small" onClick={() => handleQuickUpdate(record.localKey, !record.value)}>
</Button>
)}
{record.prefKey === 'app.language' && (
<Button size="small" onClick={() => handleQuickUpdate(record.localKey, record.value === 'zh-CN' ? 'en-US' : 'zh-CN')}>
</Button>
)}
</Space>
)
}
]
// Transform data for table
const tableData = Object.keys(currentKeys).map((localKey, index) => ({
key: index,
localKey,
prefKey: currentKeys[localKey as keyof typeof currentKeys],
value: values[localKey],
type: typeof values[localKey]
}))
return (
<TestContainer>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{/* Scenario Selection */}
<Card size="small" title="测试场景选择">
<Space align="center" wrap>
<Text>:</Text>
<Select value={scenario} onChange={setScenario} style={{ width: 200 }}>
<Option value="basic"> (theme, language, zoom)</Option>
<Option value="ui">UI设置 (theme, zoom, spell)</Option>
<Option value="user"> (tray, userName, devMode)</Option>
<Option value="custom"> (4)</Option>
</Select>
<Text type="secondary">: {Object.keys(currentKeys).length} </Text>
</Space>
</Card>
{/* Current Values Table */}
<Card size="small" title="当前值状态">
<Table
columns={columns}
dataSource={tableData}
pagination={false}
size="small"
bordered
/>
</Card>
{/* Batch Update */}
<Card size="small" title="批量更新">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Space>
<Button onClick={generateSampleBatch}>
</Button>
<Button type="primary" onClick={handleBatchUpdate} disabled={!batchInput.trim()}>
</Button>
</Space>
<Input.TextArea
value={batchInput}
onChange={(e) => setBatchInput(e.target.value)}
placeholder="输入JSON格式的批量更新数据例如: {&quot;theme&quot;: &quot;dark&quot;, &quot;language&quot;: &quot;en-US&quot;}"
rows={6}
style={{ fontFamily: 'monospace' }}
/>
<Text type="secondary" style={{ fontSize: '12px' }}>
: &#123;"localKey": "newValue", ...&#125; - 使
</Text>
</Space>
</Card>
{/* Quick Actions */}
<Card size="small" title="快速操作">
<Space wrap>
<Button
onClick={() => updateValues(Object.fromEntries(Object.keys(currentKeys).map(key => [key, 'test_value'])))}>
</Button>
<Button
onClick={() => updateValues(Object.fromEntries(Object.keys(currentKeys).map(key => [key, undefined])))}>
</Button>
<Button
onClick={async () => {
const toggles: Record<string, any> = {}
Object.entries(currentKeys).forEach(([localKey, prefKey]) => {
if (prefKey === 'app.theme.mode') {
toggles[localKey] = values[localKey] === 'ThemeMode.dark' ? 'ThemeMode.light' : 'ThemeMode.dark'
} else if (prefKey === 'app.spell_check.enabled') {
toggles[localKey] = !values[localKey]
}
})
if (Object.keys(toggles).length > 0) {
await updateValues(toggles)
message.success('切换操作完成')
}
}}>
/
</Button>
</Space>
</Card>
{/* Hook Info */}
<Card size="small" title="Hook 信息">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Text>
: <Text code>{JSON.stringify(currentKeys, null, 2)}</Text>
</Text>
<Text>
: <Text strong>{Object.keys(values).length}</Text>
</Text>
<Text>
: <Text strong>{Object.values(values).filter(v => v !== undefined).length}</Text>
</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
usePreferences 使
</Text>
</Space>
</Card>
</Space>
</TestContainer>
)
}
const TestContainer = styled.div`
padding: 16px;
background: #fafafa;
border-radius: 8px;
`
const ValueDisplay = styled.span`
font-family: 'Courier New', monospace;
font-size: 12px;
word-break: break-all;
`
export default PreferenceMultipleTests

View File

@ -0,0 +1,218 @@
import { preferenceService } from '@renderer/data/PreferenceService'
import type { PreferenceKeyType } from '@shared/data/types'
import { Button, Input, message, Space, Typography } from 'antd'
import React, { useState } from 'react'
import styled from 'styled-components'
const { Text } = Typography
/**
* PreferenceService direct API testing component
* Tests the service layer functionality without React hooks
*/
const PreferenceServiceTests: React.FC = () => {
const [testKey, setTestKey] = useState<string>('app.theme.mode')
const [testValue, setTestValue] = useState<string>('ThemeMode.dark')
const [getResult, setGetResult] = useState<any>(null)
const [loading, setLoading] = useState(false)
const handleGet = async () => {
try {
setLoading(true)
const result = await preferenceService.get(testKey as PreferenceKeyType)
setGetResult(result)
message.success('获取成功')
} catch (error) {
message.error(`获取失败: ${(error as Error).message}`)
setGetResult(`Error: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
const handleSet = async () => {
try {
setLoading(true)
let parsedValue: any = testValue
// Try to parse as JSON if it looks like an object/array/boolean/number
if (testValue.startsWith('{') || testValue.startsWith('[') || testValue === 'true' || testValue === 'false' || !isNaN(Number(testValue))) {
try {
parsedValue = JSON.parse(testValue)
} catch {
// Keep as string if JSON parsing fails
}
}
await preferenceService.set(testKey as PreferenceKeyType, parsedValue)
message.success('设置成功')
// Automatically get the updated value
await handleGet()
} catch (error) {
message.error(`设置失败: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
const handleGetCached = () => {
try {
const result = preferenceService.getCachedValue(testKey as PreferenceKeyType)
setGetResult(result !== undefined ? result : 'undefined (not cached)')
message.info('获取缓存值成功')
} catch (error) {
message.error(`获取缓存失败: ${(error as Error).message}`)
setGetResult(`Error: ${(error as Error).message}`)
}
}
const handleIsCached = () => {
try {
const result = preferenceService.isCached(testKey as PreferenceKeyType)
setGetResult(result ? 'true (已缓存)' : 'false (未缓存)')
message.info('检查缓存状态成功')
} catch (error) {
message.error(`检查缓存失败: ${(error as Error).message}`)
}
}
const handlePreload = async () => {
try {
setLoading(true)
await preferenceService.preload([testKey as PreferenceKeyType])
message.success('预加载成功')
await handleGet()
} catch (error) {
message.error(`预加载失败: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
const handleGetAll = async () => {
try {
setLoading(true)
// Get multiple keys to simulate getAll functionality
const sampleKeys = ['app.theme.mode', 'app.language', 'app.zoom_factor', 'app.spell_check.enabled', 'app.user.name'] as PreferenceKeyType[]
const result = await preferenceService.getMultiple(sampleKeys)
setGetResult(`Sample preferences (${Object.keys(result).length} keys):\n${JSON.stringify(result, null, 2)}`)
message.success('获取示例偏好设置成功')
} catch (error) {
message.error(`获取偏好设置失败: ${(error as Error).message}`)
setGetResult(`Error: ${(error as Error).message}`)
} finally {
setLoading(false)
}
}
return (
<TestContainer>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{/* Input Controls */}
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<div>
<Text strong>Preference Key:</Text>
<Input
value={testKey}
onChange={(e) => setTestKey(e.target.value)}
placeholder="Enter preference key (e.g., app.theme)"
style={{ marginTop: 4 }}
/>
</div>
<div>
<Text strong>Value:</Text>
<Input
value={testValue}
onChange={(e) => setTestValue(e.target.value)}
placeholder="Enter value (JSON format for objects/arrays)"
style={{ marginTop: 4 }}
/>
</div>
</Space>
{/* Action Buttons */}
<Space wrap>
<Button type="primary" onClick={handleGet} loading={loading}>
Get
</Button>
<Button onClick={handleSet} loading={loading}>
Set
</Button>
<Button onClick={handleGetCached}>
Get Cached
</Button>
<Button onClick={handleIsCached}>
Is Cached
</Button>
<Button onClick={handlePreload} loading={loading}>
Preload
</Button>
<Button onClick={handleGetAll} loading={loading}>
Get All
</Button>
</Space>
{/* Result Display */}
{getResult !== null && (
<ResultContainer>
<Text strong>Result:</Text>
<ResultText>{typeof getResult === 'object' ? JSON.stringify(getResult, null, 2) : String(getResult)}</ResultText>
</ResultContainer>
)}
{/* Quick Test Buttons */}
<Space wrap>
<Button
size="small"
onClick={() => {
setTestKey('app.theme.mode')
setTestValue('ThemeMode.dark')
}}>
Test: app.theme.mode
</Button>
<Button
size="small"
onClick={() => {
setTestKey('app.language')
setTestValue('zh-CN')
}}>
Test: app.language
</Button>
<Button
size="small"
onClick={() => {
setTestKey('app.spell_check.enabled')
setTestValue('true')
}}>
Test: app.spell_check.enabled
</Button>
</Space>
</Space>
</TestContainer>
)
}
const TestContainer = styled.div`
padding: 16px;
background: #fafafa;
border-radius: 8px;
`
const ResultContainer = styled.div`
margin-top: 16px;
padding: 12px;
background: #f0f0f0;
border-radius: 6px;
border-left: 4px solid var(--color-primary);
`
const ResultText = styled.pre`
margin: 8px 0 0 0;
font-family: 'Courier New', monospace;
font-size: 12px;
color: #333;
white-space: pre-wrap;
word-break: break-all;
`
export default PreferenceServiceTests

View File

@ -0,0 +1,13 @@
import '@ant-design/v5-patch-for-react-19'
import '@renderer/assets/styles/index.scss'
import { loggerService } from '@logger'
import { createRoot } from 'react-dom/client'
import TestApp from './TestApp'
loggerService.initWindowSource('DataRefactorTestWindow')
const root = createRoot(document.getElementById('root') as HTMLElement)
root.render(<TestApp />)