mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-19 06:30:10 +08:00
refactor: remove obsolete data refactor migration components and related tests
- Deleted the DataRefactorMigrateService and associated HTML files, as they are no longer needed. - Removed test components and files related to data refactor migration, streamlining the codebase. - Updated configuration files to reflect the removal of the data refactor migration functionality.
This commit is contained in:
parent
7bd3e047d2
commit
46f2726a63
@ -134,7 +134,6 @@ export default defineConfig({
|
||||
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
|
||||
dataRefactorMigrate: resolve(__dirname, 'src/renderer/dataRefactorMigrate.html'),
|
||||
migrationV2: resolve(__dirname, 'src/renderer/migrationV2.html')
|
||||
},
|
||||
onwarn(warning, warn) {
|
||||
|
||||
@ -140,7 +140,7 @@ export default defineConfig([
|
||||
{
|
||||
// Component Rules - prevent importing antd components when migration completed
|
||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||
ignores: ['src/renderer/src/windows/dataRefactorTest/**/*.{ts,tsx}'],
|
||||
ignores: [],
|
||||
rules: {
|
||||
// 'no-restricted-imports': [
|
||||
// 'error',
|
||||
|
||||
60
packages/shared/data/cache/cacheSchemas.ts
vendored
60
packages/shared/data/cache/cacheSchemas.ts
vendored
@ -26,20 +26,6 @@ export type UseCacheSchema = {
|
||||
'topic.active': CacheValueTypes.CacheTopic | null
|
||||
'topic.renaming': string[]
|
||||
'topic.newly_renamed': string[]
|
||||
|
||||
// Test keys (for dataRefactorTest window)
|
||||
// TODO: remove after testing
|
||||
'test-hook-memory-1': string
|
||||
'test-ttl-cache': string
|
||||
'test-protected-cache': string
|
||||
'test-deep-equal': { nested: { count: number }; tags: string[] }
|
||||
'test-performance': number
|
||||
'test-multi-hook': string
|
||||
'concurrent-test-1': number
|
||||
'concurrent-test-2': number
|
||||
'large-data-test': Record<string, any>
|
||||
'test-number-cache': number
|
||||
'test-object-cache': { name: string; count: number; active: boolean }
|
||||
}
|
||||
|
||||
export const DefaultUseCache: UseCacheSchema = {
|
||||
@ -70,21 +56,7 @@ export const DefaultUseCache: UseCacheSchema = {
|
||||
// Topic management
|
||||
'topic.active': null,
|
||||
'topic.renaming': [],
|
||||
'topic.newly_renamed': [],
|
||||
|
||||
// Test keys (for dataRefactorTest window)
|
||||
// TODO: remove after testing
|
||||
'test-hook-memory-1': 'default-memory-value',
|
||||
'test-ttl-cache': 'test-ttl-cache',
|
||||
'test-protected-cache': 'protected-value',
|
||||
'test-deep-equal': { nested: { count: 0 }, tags: ['initial'] },
|
||||
'test-performance': 0,
|
||||
'test-multi-hook': 'hook-1-default',
|
||||
'concurrent-test-1': 0,
|
||||
'concurrent-test-2': 0,
|
||||
'large-data-test': {},
|
||||
'test-number-cache': 42,
|
||||
'test-object-cache': { name: 'test', count: 0, active: true }
|
||||
'topic.newly_renamed': []
|
||||
}
|
||||
|
||||
/**
|
||||
@ -92,22 +64,10 @@ export const DefaultUseCache: UseCacheSchema = {
|
||||
*/
|
||||
export type UseSharedCacheSchema = {
|
||||
'example-key': string
|
||||
|
||||
// Test keys (for dataRefactorTest window)
|
||||
// TODO: remove after testing
|
||||
'test-hook-shared-1': string
|
||||
'test-multi-hook': string
|
||||
'concurrent-shared': number
|
||||
}
|
||||
|
||||
export const DefaultUseSharedCache: UseSharedCacheSchema = {
|
||||
'example-key': 'example default value',
|
||||
|
||||
// Test keys (for dataRefactorTest window)
|
||||
// TODO: remove after testing
|
||||
'concurrent-shared': 0,
|
||||
'test-hook-shared-1': 'default-shared-value',
|
||||
'test-multi-hook': 'hook-3-shared'
|
||||
'example-key': 'example default value'
|
||||
}
|
||||
|
||||
/**
|
||||
@ -116,24 +76,10 @@ export const DefaultUseSharedCache: UseSharedCacheSchema = {
|
||||
*/
|
||||
export type RendererPersistCacheSchema = {
|
||||
'example-key': string
|
||||
|
||||
// Test keys (for dataRefactorTest window)
|
||||
// TODO: remove after testing
|
||||
'example-1': string
|
||||
'example-2': string
|
||||
'example-3': string
|
||||
'example-4': string
|
||||
}
|
||||
|
||||
export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
|
||||
'example-key': 'example default value',
|
||||
|
||||
// Test keys (for dataRefactorTest window)
|
||||
// TODO: remove after testing
|
||||
'example-1': 'example default value',
|
||||
'example-2': 'example default value',
|
||||
'example-3': 'example default value',
|
||||
'example-4': 'example default value'
|
||||
'example-key': 'example default value'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -30,7 +30,7 @@ src/main/data/
|
||||
│ # - TopicRepository.ts # Complex: Topic data access
|
||||
│ # - MessageRepository.ts # Complex: Message data access
|
||||
│
|
||||
├── db/ # Database layer
|
||||
├── db/ # Database layer
|
||||
│ ├── schemas/ # Drizzle table definitions
|
||||
│ │ ├── preference.ts # Preference configuration table
|
||||
│ │ ├── appState.ts # Application state table
|
||||
@ -38,8 +38,8 @@ src/main/data/
|
||||
│ ├── seeding/ # Database initialization
|
||||
│ └── DbService.ts # Database connection and management
|
||||
│
|
||||
├── migrate/ # Data migration system
|
||||
│ └── dataRefactor/ # v2 data refactoring migration tools
|
||||
├── migration/ # Data migration system
|
||||
│ └── v2/ # v2 data refactoring migration tools
|
||||
│
|
||||
├── CacheService.ts # Infrastructure: Cache management
|
||||
├── DataApiService.ts # Infrastructure: API coordination
|
||||
|
||||
@ -1,983 +0,0 @@
|
||||
import { dbService } from '@data/db/DbService'
|
||||
import { appStateTable } from '@data/db/schemas/appState'
|
||||
import { loggerService } from '@logger'
|
||||
import { isDev } from '@main/constant'
|
||||
import BackupManager from '@main/services/BackupManager'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { app as electronApp } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
import { PreferencesMigrator } from './migrators/PreferencesMigrator'
|
||||
|
||||
const logger = loggerService.withContext('DataRefactorMigrateService')
|
||||
|
||||
const DATA_REFACTOR_MIGRATION_STATUS = 'data_refactor_migration_status'
|
||||
|
||||
// Data refactor migration status interface
|
||||
interface DataRefactorMigrationStatus {
|
||||
completed: boolean
|
||||
completedAt?: number
|
||||
version?: string
|
||||
}
|
||||
|
||||
type MigrationStage =
|
||||
| 'introduction' // Introduction phase - user can cancel
|
||||
| 'backup_required' // Backup required - show backup requirement
|
||||
| 'backup_progress' // Backup in progress - user is backing up
|
||||
| 'backup_confirmed' // Backup confirmed - ready to migrate
|
||||
| 'migration' // Migration in progress - cannot cancel
|
||||
| 'completed' // Completed - restart app
|
||||
| 'error' // Error - recovery options
|
||||
|
||||
interface MigrationProgress {
|
||||
stage: MigrationStage
|
||||
progress: number
|
||||
total: number
|
||||
message: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface MigrationResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
migratedCount: number
|
||||
}
|
||||
|
||||
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 = {
|
||||
stage: 'introduction',
|
||||
progress: 0,
|
||||
total: 100,
|
||||
message: 'Ready to start data migration'
|
||||
}
|
||||
private isMigrating: boolean = false
|
||||
private reduxData: any = null // Cache for Redux persist data
|
||||
|
||||
constructor() {
|
||||
this.backupManager = new BackupManager()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup manager instance for integration with existing backup system
|
||||
*/
|
||||
public getBackupManager(): BackupManager {
|
||||
return this.backupManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached Redux persist data for migration
|
||||
*/
|
||||
public getReduxData(): any {
|
||||
return this.reduxData
|
||||
}
|
||||
|
||||
/**
|
||||
* Set Redux persist data from renderer process
|
||||
*/
|
||||
public setReduxData(data: any): void {
|
||||
this.reduxData = data
|
||||
logger.info('Redux data cached for migration', {
|
||||
dataKeys: data ? Object.keys(data) : [],
|
||||
hasData: !!data
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register migration-specific IPC handlers
|
||||
* This creates an isolated IPC environment only for migration operations
|
||||
*/
|
||||
public registerMigrationIpcHandlers(): void {
|
||||
logger.info('Registering migration-specific IPC handlers')
|
||||
|
||||
// Only register the minimal IPC handlers needed for migration
|
||||
ipcMain.handle(IpcChannel.DataMigrate_CheckNeeded, async () => {
|
||||
try {
|
||||
return await this.isMigrated()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: checkMigrationNeeded', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_ProceedToBackup, async () => {
|
||||
try {
|
||||
await this.proceedToBackup()
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: proceedToBackup', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_StartMigration, async () => {
|
||||
try {
|
||||
await this.startMigrationProcess()
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: startMigrationProcess', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_RetryMigration, async () => {
|
||||
try {
|
||||
await this.retryMigration()
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: retryMigration', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_GetProgress, () => {
|
||||
try {
|
||||
return this.getCurrentProgress()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: getCurrentProgress', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_Cancel, async () => {
|
||||
try {
|
||||
return await this.cancelMigration()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: cancelMigration', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_BackupCompleted, async () => {
|
||||
try {
|
||||
await this.notifyBackupCompleted()
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: notifyBackupCompleted', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_ShowBackupDialog, async () => {
|
||||
try {
|
||||
logger.info('Opening backup dialog for migration')
|
||||
|
||||
// Update progress to indicate backup dialog is opening
|
||||
// await this.updateProgress('backup_progress', 10, 'Opening backup dialog...')
|
||||
|
||||
// Instead of performing backup automatically, let's open the file dialog
|
||||
// and let the user choose where to save the backup
|
||||
const { dialog } = await import('electron')
|
||||
const result = await dialog.showSaveDialog({
|
||||
title: 'Save Migration Backup',
|
||||
defaultPath: `cherry-studio-migration-backup-${new Date().toISOString().split('T')[0]}.zip`,
|
||||
filters: [
|
||||
{ name: 'Backup Files', extensions: ['zip'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
|
||||
if (!result.canceled && result.filePath) {
|
||||
logger.info('User selected backup location', { filePath: result.filePath })
|
||||
await this.updateProgress('backup_progress', 10, 'Creating backup file...')
|
||||
|
||||
// Perform the actual backup to the selected location
|
||||
const backupResult = await this.performBackupToFile(result.filePath)
|
||||
|
||||
if (backupResult.success) {
|
||||
await this.updateProgress('backup_progress', 100, 'Backup created successfully!')
|
||||
// Wait a moment to show the success message, then transition to confirmed state
|
||||
setTimeout(async () => {
|
||||
await this.updateProgress(
|
||||
'backup_confirmed',
|
||||
100,
|
||||
'Backup completed! Ready to start migration. Click "Start Migration" to continue.'
|
||||
)
|
||||
}, 1000)
|
||||
} else {
|
||||
await this.updateProgress('backup_required', 0, `Backup failed: ${backupResult.error}`)
|
||||
}
|
||||
|
||||
return backupResult
|
||||
} else {
|
||||
logger.info('User cancelled backup dialog')
|
||||
await this.updateProgress('backup_required', 0, 'Backup cancelled. Please create a backup to continue.')
|
||||
return { success: false, error: 'Backup cancelled by user' }
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: showBackupDialog', error as Error)
|
||||
await this.updateProgress('backup_required', 0, 'Backup process failed')
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_StartFlow, async () => {
|
||||
try {
|
||||
return await this.startMigrationFlow()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: startMigrationFlow', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_RestartApp, async () => {
|
||||
try {
|
||||
await this.restartApplication()
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: restartApplication', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_CloseWindow, () => {
|
||||
try {
|
||||
this.closeMigrateWindow()
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: closeMigrateWindow', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_SendReduxData, (_event, data) => {
|
||||
try {
|
||||
this.setReduxData(data)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: sendReduxData', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(IpcChannel.DataMigrate_GetReduxData, () => {
|
||||
try {
|
||||
return this.getReduxData()
|
||||
} catch (error) {
|
||||
logger.error('IPC handler error: getReduxData', error as Error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Migration IPC handlers registered successfully')
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove migration-specific IPC handlers
|
||||
* Clean up when migration is complete or cancelled
|
||||
*/
|
||||
public unregisterMigrationIpcHandlers(): void {
|
||||
logger.info('Unregistering migration-specific IPC handlers')
|
||||
|
||||
try {
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_CheckNeeded)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_GetProgress)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_Cancel)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_BackupCompleted)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_ShowBackupDialog)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_StartFlow)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_ProceedToBackup)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_StartMigration)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_RetryMigration)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_RestartApp)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_CloseWindow)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_SendReduxData)
|
||||
ipcMain.removeAllListeners(IpcChannel.DataMigrate_GetReduxData)
|
||||
|
||||
logger.info('Migration IPC handlers unregistered successfully')
|
||||
} catch (error) {
|
||||
logger.warn('Error unregistering migration IPC handlers', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): DataRefactorMigrateService {
|
||||
if (!DataRefactorMigrateService.instance) {
|
||||
DataRefactorMigrateService.instance = new 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
|
||||
*/
|
||||
async isMigrated(): Promise<boolean> {
|
||||
try {
|
||||
const isMigrated = await this.isMigrationCompleted()
|
||||
if (isMigrated) {
|
||||
logger.info('Data Refactor Migration already completed')
|
||||
return true
|
||||
}
|
||||
|
||||
logger.info('Data Refactor Migration is needed')
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error('Failed to check migration status', error as Error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if migration is already completed
|
||||
*/
|
||||
private async isMigrationCompleted(): Promise<boolean> {
|
||||
try {
|
||||
logger.debug('Checking migration completion status in database')
|
||||
|
||||
// First check if the database is available
|
||||
if (!this.db) {
|
||||
logger.warn('Database not initialized, assuming migration not completed')
|
||||
return false
|
||||
}
|
||||
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(appStateTable)
|
||||
.where(eq(appStateTable.key, DATA_REFACTOR_MIGRATION_STATUS))
|
||||
.limit(1)
|
||||
|
||||
logger.debug('Migration status query result', { resultCount: result.length })
|
||||
|
||||
if (result.length === 0) {
|
||||
logger.info('No migration status record found, migration needed')
|
||||
return false
|
||||
}
|
||||
|
||||
const status = result[0].value as DataRefactorMigrationStatus
|
||||
const isCompleted = status.completed === true
|
||||
|
||||
logger.info('Migration status found', {
|
||||
completed: isCompleted,
|
||||
completedAt: status.completedAt,
|
||||
version: status.version
|
||||
})
|
||||
|
||||
return isCompleted
|
||||
} catch (error) {
|
||||
logger.error('Failed to check migration state - treating as not completed', error as Error)
|
||||
// In case of database errors, assume migration is needed to be safe
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark migration as completed
|
||||
*/
|
||||
private async markMigrationCompleted(): Promise<void> {
|
||||
try {
|
||||
const migrationStatus: DataRefactorMigrationStatus = {
|
||||
completed: true,
|
||||
completedAt: Date.now(),
|
||||
version: electronApp.getVersion()
|
||||
}
|
||||
|
||||
await this.db
|
||||
.insert(appStateTable)
|
||||
.values({
|
||||
key: DATA_REFACTOR_MIGRATION_STATUS,
|
||||
value: migrationStatus, // drizzle handles JSON serialization automatically
|
||||
description: 'Data refactoring migration status from legacy format (ElectronStore + Redux persist) to SQLite',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appStateTable.key,
|
||||
set: {
|
||||
value: migrationStatus,
|
||||
updatedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Migration marked as completed in app_state table', {
|
||||
version: migrationStatus.version,
|
||||
completedAt: migrationStatus.completedAt
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to mark migration as completed', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and show migration window
|
||||
*/
|
||||
private createMigrateWindow(): BrowserWindow {
|
||||
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
|
||||
this.migrateWindow.show()
|
||||
return this.migrateWindow
|
||||
}
|
||||
|
||||
// Register migration-specific IPC handlers before creating window
|
||||
this.registerMigrationIpcHandlers()
|
||||
|
||||
this.migrateWindow = new BrowserWindow({
|
||||
width: 640,
|
||||
height: 480,
|
||||
resizable: false,
|
||||
maximizable: false,
|
||||
minimizable: false,
|
||||
show: false,
|
||||
frame: false,
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/simplest.js'),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
contextIsolation: true
|
||||
}
|
||||
})
|
||||
|
||||
// Load the migration window
|
||||
if (isDev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
this.migrateWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '/dataRefactorMigrate.html')
|
||||
} else {
|
||||
this.migrateWindow.loadFile(join(__dirname, '../renderer/dataRefactorMigrate.html'))
|
||||
}
|
||||
|
||||
this.migrateWindow.once('ready-to-show', () => {
|
||||
this.migrateWindow?.show()
|
||||
})
|
||||
|
||||
this.migrateWindow.on('closed', () => {
|
||||
this.migrateWindow = null
|
||||
// Clean up IPC handlers when window is closed
|
||||
this.unregisterMigrationIpcHandlers()
|
||||
})
|
||||
|
||||
logger.info('Migration window created')
|
||||
return this.migrateWindow
|
||||
}
|
||||
|
||||
/**
|
||||
* Show migration window and initialize introduction stage
|
||||
*/
|
||||
async runMigration(): Promise<void> {
|
||||
if (this.isMigrating) {
|
||||
logger.warn('Migration already in progress')
|
||||
this.migrateWindow?.show()
|
||||
return
|
||||
}
|
||||
|
||||
this.isMigrating = true
|
||||
logger.info('Showing migration window')
|
||||
|
||||
// Initialize introduction stage
|
||||
await this.updateProgress('introduction', 0, 'Welcome to Cherry Studio data migration')
|
||||
|
||||
// Create migration window
|
||||
const window = this.createMigrateWindow()
|
||||
|
||||
// Wait for window to be ready
|
||||
await new Promise<void>((resolve) => {
|
||||
if (window.webContents.isLoading()) {
|
||||
window.webContents.once('did-finish-load', () => resolve())
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start migration flow - simply ensure we're in introduction stage
|
||||
* This is called when user first opens the migration window
|
||||
*/
|
||||
async startMigrationFlow(): Promise<void> {
|
||||
if (!this.isMigrating) {
|
||||
logger.warn('Migration not started, cannot execute flow.')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Confirming introduction stage for migration flow')
|
||||
await this.updateProgress('introduction', 0, 'Ready to begin migration process. Please read the information below.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Proceed from introduction to backup requirement stage
|
||||
* This is called when user clicks "Next" in introduction
|
||||
*/
|
||||
async proceedToBackup(): Promise<void> {
|
||||
if (!this.isMigrating) {
|
||||
logger.warn('Migration not started, cannot proceed to backup.')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Proceeding from introduction to backup stage')
|
||||
await this.updateProgress('backup_required', 0, 'Data backup is required before migration can proceed')
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the actual migration process
|
||||
* This is called when user confirms backup and clicks "Start Migration"
|
||||
*/
|
||||
async startMigrationProcess(): Promise<void> {
|
||||
if (!this.isMigrating) {
|
||||
logger.warn('Migration not started, cannot start migration process.')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Starting actual migration process')
|
||||
try {
|
||||
await this.executeMigrationFlow()
|
||||
} catch (error) {
|
||||
logger.error('Migration process failed', error as Error)
|
||||
// error is already handled in executeMigrationFlow
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual migration process
|
||||
* Called after user has confirmed backup completion
|
||||
*/
|
||||
private async executeMigrationFlow(): Promise<void> {
|
||||
try {
|
||||
// Start migration
|
||||
await this.updateProgress('migration', 0, 'Starting data migration...')
|
||||
const migrationResult = await this.executeMigration()
|
||||
|
||||
if (!migrationResult.success) {
|
||||
throw new Error(migrationResult.error || 'Migration failed')
|
||||
}
|
||||
|
||||
await this.updateProgress(
|
||||
'migration',
|
||||
100,
|
||||
`Migration completed: ${migrationResult.migratedCount} items migrated`
|
||||
)
|
||||
|
||||
// Mark as completed
|
||||
await this.markMigrationCompleted()
|
||||
|
||||
await this.updateProgress('completed', 100, 'Migration completed successfully! Click restart to continue.')
|
||||
} catch (error) {
|
||||
logger.error('Migration flow failed', error as Error)
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
await this.updateProgress(
|
||||
'error',
|
||||
0,
|
||||
'Migration failed. You can close this window and try again, or continue using the previous version.',
|
||||
errorMessage
|
||||
)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform backup to a specific file location
|
||||
*/
|
||||
private async performBackupToFile(filePath: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
logger.info('Performing backup to file', { filePath })
|
||||
|
||||
// Get backup data from the current application state
|
||||
const backupData = await this.getBackupData()
|
||||
|
||||
// Extract directory and filename from the full path
|
||||
const path = await import('path')
|
||||
const destinationDir = path.dirname(filePath)
|
||||
const fileName = path.basename(filePath)
|
||||
|
||||
// Use the existing backup manager to create a backup
|
||||
const backupPath = await this.backupManager.backup(
|
||||
null as any, // IpcMainInvokeEvent - we're calling directly so pass null
|
||||
fileName,
|
||||
backupData,
|
||||
destinationDir,
|
||||
false // Don't skip backup files - full backup for migration safety
|
||||
)
|
||||
|
||||
if (backupPath) {
|
||||
logger.info('Backup created successfully', { path: backupPath })
|
||||
return { success: true }
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Backup process did not return a file path'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logger.error('Backup failed during migration:', error as Error)
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get backup data from the current application
|
||||
* This creates a minimal backup with essential system information
|
||||
*/
|
||||
private async getBackupData(): Promise<string> {
|
||||
try {
|
||||
const fs = await import('fs-extra')
|
||||
const path = await import('path')
|
||||
|
||||
// Gather basic system information
|
||||
const data = {
|
||||
backup: {
|
||||
timestamp: new Date().toISOString(),
|
||||
version: electronApp.getVersion(),
|
||||
type: 'pre-migration-backup',
|
||||
note: 'This is a safety backup created before data migration'
|
||||
},
|
||||
system: {
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
nodeVersion: process.version
|
||||
},
|
||||
// Include basic configuration files if they exist
|
||||
configs: {} as Record<string, any>
|
||||
}
|
||||
|
||||
// Try to read some basic configuration files (non-critical if they fail)
|
||||
try {
|
||||
const { getDataPath } = await import('@main/utils')
|
||||
const dataPath = getDataPath()
|
||||
|
||||
// Check if there are any config files we should backup
|
||||
const configFiles = ['config.json', 'settings.json', 'preferences.json']
|
||||
for (const configFile of configFiles) {
|
||||
const configPath = path.join(dataPath, configFile)
|
||||
if (await fs.pathExists(configPath)) {
|
||||
try {
|
||||
const configContent = await fs.readJson(configPath)
|
||||
data.configs[configFile] = configContent
|
||||
} catch (err) {
|
||||
logger.warn(`Could not read config file ${configFile}`, err as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Could not access data directory for config backup', err as Error)
|
||||
}
|
||||
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch (error) {
|
||||
logger.error('Failed to get backup data:', error as Error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that backup has been completed (called from IPC handler)
|
||||
*/
|
||||
public async notifyBackupCompleted(): Promise<void> {
|
||||
logger.info('Backup completed by user')
|
||||
await this.updateProgress(
|
||||
'backup_confirmed',
|
||||
100,
|
||||
'Backup completed! Ready to start migration. Click "Start Migration" to continue.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual migration
|
||||
*/
|
||||
private async executeMigration(): Promise<MigrationResult> {
|
||||
try {
|
||||
logger.info('Executing migration')
|
||||
|
||||
// Create preferences migrator with reference to this service for Redux data access
|
||||
const preferencesMigrator = new PreferencesMigrator(this)
|
||||
|
||||
// Execute preferences migration with progress updates
|
||||
const result = await preferencesMigrator.migrate((progress, message) => {
|
||||
this.updateProgress('migration', progress, message)
|
||||
})
|
||||
|
||||
logger.info('Migration execution completed', result)
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
migratedCount: result.migratedCount,
|
||||
error: result.errors.length > 0 ? result.errors.map((e) => e.error).join('; ') : undefined
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Migration execution failed', error as Error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
migratedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update migration progress and broadcast to window
|
||||
*/
|
||||
private async updateProgress(
|
||||
stage: MigrationStage,
|
||||
progress: number,
|
||||
message: string,
|
||||
error?: string
|
||||
): Promise<void> {
|
||||
this.currentProgress = {
|
||||
stage,
|
||||
progress,
|
||||
total: 100,
|
||||
message,
|
||||
error
|
||||
}
|
||||
|
||||
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
|
||||
this.migrateWindow.webContents.send(IpcChannel.DataMigrateProgress, this.currentProgress)
|
||||
}
|
||||
|
||||
logger.debug('Progress updated', this.currentProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current migration progress
|
||||
*/
|
||||
getCurrentProgress(): MigrationProgress {
|
||||
return this.currentProgress
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel migration process
|
||||
* Only allowed during introduction and backup phases
|
||||
*/
|
||||
async cancelMigration(): Promise<void> {
|
||||
if (!this.isMigrating) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentStage = this.currentProgress.stage
|
||||
if (currentStage === 'migration') {
|
||||
logger.warn('Cannot cancel migration during migration process')
|
||||
return
|
||||
}
|
||||
|
||||
logger.info('Cancelling migration process')
|
||||
this.isMigrating = false
|
||||
this.closeMigrateWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry migration after error
|
||||
*/
|
||||
async retryMigration(): Promise<void> {
|
||||
logger.info('Retrying migration process')
|
||||
await this.updateProgress(
|
||||
'introduction',
|
||||
0,
|
||||
'Ready to restart migration process. Please read the information below.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close migration window
|
||||
*/
|
||||
private closeMigrateWindow(): void {
|
||||
if (this.migrateWindow && !this.migrateWindow.isDestroyed()) {
|
||||
this.migrateWindow.close()
|
||||
this.migrateWindow = null
|
||||
}
|
||||
|
||||
this.isMigrating = false
|
||||
// Clean up migration-specific IPC handlers
|
||||
this.unregisterMigrationIpcHandlers()
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the application after successful migration
|
||||
*/
|
||||
private async restartApplication(): Promise<void> {
|
||||
try {
|
||||
logger.info('Preparing to restart application after migration completion')
|
||||
|
||||
// Ensure migration status is properly saved before restart
|
||||
await this.verifyMigrationStatus()
|
||||
|
||||
// Give some time for database operations to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
|
||||
logger.info('Restarting application now')
|
||||
|
||||
// In development mode, relaunch might not work properly
|
||||
if (process.env.NODE_ENV === 'development' || !app.isPackaged) {
|
||||
logger.warn('Development mode detected - showing restart instruction instead of auto-restart')
|
||||
|
||||
const { dialog } = await import('electron')
|
||||
await dialog.showMessageBox({
|
||||
type: 'info',
|
||||
title: 'Migration Complete - Restart Required',
|
||||
message:
|
||||
'Data migration completed successfully!\n\nSince you are in development mode, please manually restart the application to continue.',
|
||||
buttons: ['Close App'],
|
||||
defaultId: 0
|
||||
})
|
||||
|
||||
// Clean up migration window and handlers after showing dialog
|
||||
this.closeMigrateWindow()
|
||||
app.quit()
|
||||
} else {
|
||||
// Production mode - clean up first, then relaunch
|
||||
this.closeMigrateWindow()
|
||||
app.relaunch()
|
||||
app.exit(0)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to restart application', error as Error)
|
||||
// Update UI to show restart failure and provide manual restart instruction
|
||||
await this.updateProgress(
|
||||
'error',
|
||||
0,
|
||||
'Application restart failed. Please manually restart the application to complete migration.',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
// Fallback: just close migration window and let user manually restart
|
||||
this.closeMigrateWindow()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that migration status is properly saved
|
||||
*/
|
||||
private async verifyMigrationStatus(): Promise<void> {
|
||||
try {
|
||||
const isCompleted = await this.isMigrationCompleted()
|
||||
if (isCompleted) {
|
||||
logger.info('Migration status verified as completed')
|
||||
} else {
|
||||
logger.warn('Migration status not found as completed, attempting to mark again')
|
||||
await this.markMigrationCompleted()
|
||||
|
||||
// Double-check
|
||||
const recheck = await this.isMigrationCompleted()
|
||||
if (recheck) {
|
||||
logger.info('Migration status successfully marked as completed on retry')
|
||||
} else {
|
||||
logger.error('Failed to mark migration as completed even on retry')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify migration status', error as Error)
|
||||
// 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
|
||||
export const dataRefactorMigrateService = DataRefactorMigrateService.getInstance()
|
||||
@ -1,755 +0,0 @@
|
||||
/**
|
||||
* Auto-generated preference mappings from classification.json
|
||||
* Generated at: 2025-09-02T06:27:50.213Z
|
||||
*
|
||||
* This file contains pure mapping relationships without default values.
|
||||
* Default values are managed in packages/shared/data/preferences.ts
|
||||
*
|
||||
* === AUTO-GENERATED CONTENT START ===
|
||||
*/
|
||||
|
||||
/**
|
||||
* ElectronStore映射关系 - 简单一层结构
|
||||
*
|
||||
* ElectronStore没有嵌套,originalKey直接对应configManager.get(key)
|
||||
*/
|
||||
export const ELECTRON_STORE_MAPPINGS = [
|
||||
{
|
||||
originalKey: 'ZoomFactor',
|
||||
targetKey: 'app.zoom_factor'
|
||||
}
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Redux Store映射关系 - 按category分组,支持嵌套路径
|
||||
*
|
||||
* Redux Store可能有children结构,originalKey可能包含嵌套路径:
|
||||
* - 直接字段: "theme" -> reduxData.settings.theme
|
||||
* - 嵌套字段: "codeEditor.enabled" -> reduxData.settings.codeEditor.enabled
|
||||
* - 多层嵌套: "exportMenuOptions.docx" -> reduxData.settings.exportMenuOptions.docx
|
||||
*/
|
||||
export const REDUX_STORE_MAPPINGS = {
|
||||
settings: [
|
||||
{
|
||||
originalKey: 'autoCheckUpdate',
|
||||
targetKey: 'app.dist.auto_update.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'clickTrayToShowQuickAssistant',
|
||||
targetKey: 'feature.quick_assistant.click_tray_to_show'
|
||||
},
|
||||
{
|
||||
originalKey: 'disableHardwareAcceleration',
|
||||
targetKey: 'app.disable_hardware_acceleration'
|
||||
},
|
||||
{
|
||||
originalKey: 'enableDataCollection',
|
||||
targetKey: 'app.privacy.data_collection.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'enableDeveloperMode',
|
||||
targetKey: 'app.developer_mode.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'enableQuickAssistant',
|
||||
targetKey: 'feature.quick_assistant.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'language',
|
||||
targetKey: 'app.language'
|
||||
},
|
||||
{
|
||||
originalKey: 'launchToTray',
|
||||
targetKey: 'app.tray.on_launch'
|
||||
},
|
||||
{
|
||||
originalKey: 'testChannel',
|
||||
targetKey: 'app.dist.test_plan.channel'
|
||||
},
|
||||
{
|
||||
originalKey: 'testPlan',
|
||||
targetKey: 'app.dist.test_plan.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'theme',
|
||||
targetKey: 'ui.theme_mode'
|
||||
},
|
||||
{
|
||||
originalKey: 'tray',
|
||||
targetKey: 'app.tray.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'trayOnClose',
|
||||
targetKey: 'app.tray.on_close'
|
||||
},
|
||||
{
|
||||
originalKey: 'showAssistants',
|
||||
targetKey: 'assistant.tab.show'
|
||||
},
|
||||
{
|
||||
originalKey: 'showTopics',
|
||||
targetKey: 'topic.tab.show'
|
||||
},
|
||||
{
|
||||
originalKey: 'assistantsTabSortType',
|
||||
targetKey: 'assistant.tab.sort_type'
|
||||
},
|
||||
{
|
||||
originalKey: 'sendMessageShortcut',
|
||||
targetKey: 'chat.input.send_message_shortcut'
|
||||
},
|
||||
{
|
||||
originalKey: 'targetLanguage',
|
||||
targetKey: 'feature.translate.target_language'
|
||||
},
|
||||
{
|
||||
originalKey: 'proxyMode',
|
||||
targetKey: 'app.proxy.mode'
|
||||
},
|
||||
{
|
||||
originalKey: 'proxyUrl',
|
||||
targetKey: 'app.proxy.url'
|
||||
},
|
||||
{
|
||||
originalKey: 'proxyBypassRules',
|
||||
targetKey: 'app.proxy.bypass_rules'
|
||||
},
|
||||
{
|
||||
originalKey: 'userName',
|
||||
targetKey: 'app.user.name'
|
||||
},
|
||||
{
|
||||
originalKey: 'userId',
|
||||
targetKey: 'app.user.id'
|
||||
},
|
||||
{
|
||||
originalKey: 'showPrompt',
|
||||
targetKey: 'chat.message.show_prompt'
|
||||
},
|
||||
{
|
||||
originalKey: 'showMessageDivider',
|
||||
targetKey: 'chat.message.show_divider'
|
||||
},
|
||||
{
|
||||
originalKey: 'messageFont',
|
||||
targetKey: 'chat.message.font'
|
||||
},
|
||||
{
|
||||
originalKey: 'showInputEstimatedTokens',
|
||||
targetKey: 'chat.input.show_estimated_tokens'
|
||||
},
|
||||
{
|
||||
originalKey: 'launchOnBoot',
|
||||
targetKey: 'app.launch_on_boot'
|
||||
},
|
||||
{
|
||||
originalKey: 'userTheme.colorPrimary',
|
||||
targetKey: 'ui.theme_user.color_primary'
|
||||
},
|
||||
{
|
||||
originalKey: 'windowStyle',
|
||||
targetKey: 'ui.window_style'
|
||||
},
|
||||
{
|
||||
originalKey: 'fontSize',
|
||||
targetKey: 'chat.message.font_size'
|
||||
},
|
||||
{
|
||||
originalKey: 'topicPosition',
|
||||
targetKey: 'topic.position'
|
||||
},
|
||||
{
|
||||
originalKey: 'showTopicTime',
|
||||
targetKey: 'topic.tab.show_time'
|
||||
},
|
||||
{
|
||||
originalKey: 'pinTopicsToTop',
|
||||
targetKey: 'topic.tab.pin_to_top'
|
||||
},
|
||||
{
|
||||
originalKey: 'assistantIconType',
|
||||
targetKey: 'assistant.icon_type'
|
||||
},
|
||||
{
|
||||
originalKey: 'pasteLongTextAsFile',
|
||||
targetKey: 'chat.input.paste_long_text_as_file'
|
||||
},
|
||||
{
|
||||
originalKey: 'pasteLongTextThreshold',
|
||||
targetKey: 'chat.input.paste_long_text_threshold'
|
||||
},
|
||||
{
|
||||
originalKey: 'clickAssistantToShowTopic',
|
||||
targetKey: 'assistant.click_to_show_topic'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeExecution.enabled',
|
||||
targetKey: 'chat.code.execution.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeExecution.timeoutMinutes',
|
||||
targetKey: 'chat.code.execution.timeout_minutes'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeEditor.enabled',
|
||||
targetKey: 'chat.code.editor.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeEditor.themeLight',
|
||||
targetKey: 'chat.code.editor.theme_light'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeEditor.themeDark',
|
||||
targetKey: 'chat.code.editor.theme_dark'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeEditor.highlightActiveLine',
|
||||
targetKey: 'chat.code.editor.highlight_active_line'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeEditor.foldGutter',
|
||||
targetKey: 'chat.code.editor.fold_gutter'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeEditor.autocompletion',
|
||||
targetKey: 'chat.code.editor.autocompletion'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeEditor.keymap',
|
||||
targetKey: 'chat.code.editor.keymap'
|
||||
},
|
||||
{
|
||||
originalKey: 'codePreview.themeLight',
|
||||
targetKey: 'chat.code.preview.theme_light'
|
||||
},
|
||||
{
|
||||
originalKey: 'codePreview.themeDark',
|
||||
targetKey: 'chat.code.preview.theme_dark'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeViewer.themeLight',
|
||||
targetKey: 'chat.code.viewer.theme_light'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeViewer.themeDark',
|
||||
targetKey: 'chat.code.viewer.theme_dark'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeShowLineNumbers',
|
||||
targetKey: 'chat.code.show_line_numbers'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeCollapsible',
|
||||
targetKey: 'chat.code.collapsible'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeWrappable',
|
||||
targetKey: 'chat.code.wrappable'
|
||||
},
|
||||
{
|
||||
originalKey: 'codeImageTools',
|
||||
targetKey: 'chat.code.image_tools'
|
||||
},
|
||||
{
|
||||
originalKey: 'mathEngine',
|
||||
targetKey: 'chat.message.math_engine'
|
||||
},
|
||||
{
|
||||
originalKey: 'messageStyle',
|
||||
targetKey: 'chat.message.style'
|
||||
},
|
||||
{
|
||||
originalKey: 'foldDisplayMode',
|
||||
targetKey: 'chat.message.multi_model.fold_display_mode'
|
||||
},
|
||||
{
|
||||
originalKey: 'gridColumns',
|
||||
targetKey: 'chat.message.multi_model.grid_columns'
|
||||
},
|
||||
{
|
||||
originalKey: 'gridPopoverTrigger',
|
||||
targetKey: 'chat.message.multi_model.grid_popover_trigger'
|
||||
},
|
||||
{
|
||||
originalKey: 'messageNavigation',
|
||||
targetKey: 'chat.message.navigation_mode'
|
||||
},
|
||||
{
|
||||
originalKey: 'skipBackupFile',
|
||||
targetKey: 'data.backup.general.skip_backup_file'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavHost',
|
||||
targetKey: 'data.backup.webdav.host'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavUser',
|
||||
targetKey: 'data.backup.webdav.user'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavPass',
|
||||
targetKey: 'data.backup.webdav.pass'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavPath',
|
||||
targetKey: 'data.backup.webdav.path'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavAutoSync',
|
||||
targetKey: 'data.backup.webdav.auto_sync'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavSyncInterval',
|
||||
targetKey: 'data.backup.webdav.sync_interval'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavMaxBackups',
|
||||
targetKey: 'data.backup.webdav.max_backups'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavSkipBackupFile',
|
||||
targetKey: 'data.backup.webdav.skip_backup_file'
|
||||
},
|
||||
{
|
||||
originalKey: 'webdavDisableStream',
|
||||
targetKey: 'data.backup.webdav.disable_stream'
|
||||
},
|
||||
{
|
||||
originalKey: 'translateModelPrompt',
|
||||
targetKey: 'feature.translate.model_prompt'
|
||||
},
|
||||
{
|
||||
originalKey: 'autoTranslateWithSpace',
|
||||
targetKey: 'chat.input.translate.auto_translate_with_space'
|
||||
},
|
||||
{
|
||||
originalKey: 'showTranslateConfirm',
|
||||
targetKey: 'chat.input.translate.show_confirm'
|
||||
},
|
||||
{
|
||||
originalKey: 'enableTopicNaming',
|
||||
targetKey: 'topic.naming.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'customCss',
|
||||
targetKey: 'ui.custom_css'
|
||||
},
|
||||
{
|
||||
originalKey: 'topicNamingPrompt',
|
||||
targetKey: 'topic.naming.prompt'
|
||||
},
|
||||
{
|
||||
originalKey: 'narrowMode',
|
||||
targetKey: 'chat.narrow_mode'
|
||||
},
|
||||
{
|
||||
originalKey: 'multiModelMessageStyle',
|
||||
targetKey: 'chat.message.multi_model.style'
|
||||
},
|
||||
{
|
||||
originalKey: 'readClipboardAtStartup',
|
||||
targetKey: 'feature.quick_assistant.read_clipboard_at_startup'
|
||||
},
|
||||
{
|
||||
originalKey: 'notionDatabaseID',
|
||||
targetKey: 'data.integration.notion.database_id'
|
||||
},
|
||||
{
|
||||
originalKey: 'notionApiKey',
|
||||
targetKey: 'data.integration.notion.api_key'
|
||||
},
|
||||
{
|
||||
originalKey: 'notionPageNameKey',
|
||||
targetKey: 'data.integration.notion.page_name_key'
|
||||
},
|
||||
{
|
||||
originalKey: 'markdownExportPath',
|
||||
targetKey: 'data.export.markdown.path'
|
||||
},
|
||||
{
|
||||
originalKey: 'forceDollarMathInMarkdown',
|
||||
targetKey: 'data.export.markdown.force_dollar_math'
|
||||
},
|
||||
{
|
||||
originalKey: 'useTopicNamingForMessageTitle',
|
||||
targetKey: 'data.export.markdown.use_topic_naming_for_message_title'
|
||||
},
|
||||
{
|
||||
originalKey: 'showModelNameInMarkdown',
|
||||
targetKey: 'data.export.markdown.show_model_name'
|
||||
},
|
||||
{
|
||||
originalKey: 'showModelProviderInMarkdown',
|
||||
targetKey: 'data.export.markdown.show_model_provider'
|
||||
},
|
||||
{
|
||||
originalKey: 'thoughtAutoCollapse',
|
||||
targetKey: 'chat.message.thought.auto_collapse'
|
||||
},
|
||||
{
|
||||
originalKey: 'notionExportReasoning',
|
||||
targetKey: 'data.integration.notion.export_reasoning'
|
||||
},
|
||||
{
|
||||
originalKey: 'excludeCitationsInExport',
|
||||
targetKey: 'data.export.markdown.exclude_citations'
|
||||
},
|
||||
{
|
||||
originalKey: 'standardizeCitationsInExport',
|
||||
targetKey: 'data.export.markdown.standardize_citations'
|
||||
},
|
||||
{
|
||||
originalKey: 'yuqueToken',
|
||||
targetKey: 'data.integration.yuque.token'
|
||||
},
|
||||
{
|
||||
originalKey: 'yuqueUrl',
|
||||
targetKey: 'data.integration.yuque.url'
|
||||
},
|
||||
{
|
||||
originalKey: 'yuqueRepoId',
|
||||
targetKey: 'data.integration.yuque.repo_id'
|
||||
},
|
||||
{
|
||||
originalKey: 'joplinToken',
|
||||
targetKey: 'data.integration.joplin.token'
|
||||
},
|
||||
{
|
||||
originalKey: 'joplinUrl',
|
||||
targetKey: 'data.integration.joplin.url'
|
||||
},
|
||||
{
|
||||
originalKey: 'joplinExportReasoning',
|
||||
targetKey: 'data.integration.joplin.export_reasoning'
|
||||
},
|
||||
{
|
||||
originalKey: 'defaultObsidianVault',
|
||||
targetKey: 'data.integration.obsidian.default_vault'
|
||||
},
|
||||
{
|
||||
originalKey: 'siyuanApiUrl',
|
||||
targetKey: 'data.integration.siyuan.api_url'
|
||||
},
|
||||
{
|
||||
originalKey: 'siyuanToken',
|
||||
targetKey: 'data.integration.siyuan.token'
|
||||
},
|
||||
{
|
||||
originalKey: 'siyuanBoxId',
|
||||
targetKey: 'data.integration.siyuan.box_id'
|
||||
},
|
||||
{
|
||||
originalKey: 'siyuanRootPath',
|
||||
targetKey: 'data.integration.siyuan.root_path'
|
||||
},
|
||||
{
|
||||
originalKey: 'maxKeepAliveMinapps',
|
||||
targetKey: 'feature.minapp.max_keep_alive'
|
||||
},
|
||||
{
|
||||
originalKey: 'showOpenedMinappsInSidebar',
|
||||
targetKey: 'feature.minapp.show_opened_in_sidebar'
|
||||
},
|
||||
{
|
||||
originalKey: 'minappsOpenLinkExternal',
|
||||
targetKey: 'feature.minapp.open_link_external'
|
||||
},
|
||||
{
|
||||
originalKey: 'enableSpellCheck',
|
||||
targetKey: 'app.spell_check.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'spellCheckLanguages',
|
||||
targetKey: 'app.spell_check.languages'
|
||||
},
|
||||
{
|
||||
originalKey: 'enableQuickPanelTriggers',
|
||||
targetKey: 'chat.input.quick_panel.triggers_enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.image',
|
||||
targetKey: 'data.export.menus.image'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.markdown',
|
||||
targetKey: 'data.export.menus.markdown'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.markdown_reason',
|
||||
targetKey: 'data.export.menus.markdown_reason'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.notion',
|
||||
targetKey: 'data.export.menus.notion'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.yuque',
|
||||
targetKey: 'data.export.menus.yuque'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.joplin',
|
||||
targetKey: 'data.export.menus.joplin'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.obsidian',
|
||||
targetKey: 'data.export.menus.obsidian'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.siyuan',
|
||||
targetKey: 'data.export.menus.siyuan'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.docx',
|
||||
targetKey: 'data.export.menus.docx'
|
||||
},
|
||||
{
|
||||
originalKey: 'exportMenuOptions.plain_text',
|
||||
targetKey: 'data.export.menus.plain_text'
|
||||
},
|
||||
{
|
||||
originalKey: 'notification.assistant',
|
||||
targetKey: 'app.notification.assistant.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'notification.backup',
|
||||
targetKey: 'app.notification.backup.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'notification.knowledge',
|
||||
targetKey: 'app.notification.knowledge.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'localBackupDir',
|
||||
targetKey: 'data.backup.local.dir'
|
||||
},
|
||||
{
|
||||
originalKey: 'localBackupAutoSync',
|
||||
targetKey: 'data.backup.local.auto_sync'
|
||||
},
|
||||
{
|
||||
originalKey: 'localBackupSyncInterval',
|
||||
targetKey: 'data.backup.local.sync_interval'
|
||||
},
|
||||
{
|
||||
originalKey: 'localBackupMaxBackups',
|
||||
targetKey: 'data.backup.local.max_backups'
|
||||
},
|
||||
{
|
||||
originalKey: 'localBackupSkipBackupFile',
|
||||
targetKey: 'data.backup.local.skip_backup_file'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.endpoint',
|
||||
targetKey: 'data.backup.s3.endpoint'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.region',
|
||||
targetKey: 'data.backup.s3.region'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.bucket',
|
||||
targetKey: 'data.backup.s3.bucket'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.accessKeyId',
|
||||
targetKey: 'data.backup.s3.access_key_id'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.secretAccessKey',
|
||||
targetKey: 'data.backup.s3.secret_access_key'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.root',
|
||||
targetKey: 'data.backup.s3.root'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.autoSync',
|
||||
targetKey: 'data.backup.s3.auto_sync'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.syncInterval',
|
||||
targetKey: 'data.backup.s3.sync_interval'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.maxBackups',
|
||||
targetKey: 'data.backup.s3.max_backups'
|
||||
},
|
||||
{
|
||||
originalKey: 's3.skipBackupFile',
|
||||
targetKey: 'data.backup.s3.skip_backup_file'
|
||||
},
|
||||
{
|
||||
originalKey: 'navbarPosition',
|
||||
targetKey: 'ui.navbar.position'
|
||||
},
|
||||
{
|
||||
originalKey: 'apiServer.enabled',
|
||||
targetKey: 'feature.csaas.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'apiServer.host',
|
||||
targetKey: 'feature.csaas.host'
|
||||
},
|
||||
{
|
||||
originalKey: 'apiServer.port',
|
||||
targetKey: 'feature.csaas.port'
|
||||
},
|
||||
{
|
||||
originalKey: 'apiServer.apiKey',
|
||||
targetKey: 'feature.csaas.api_key'
|
||||
}
|
||||
],
|
||||
selectionStore: [
|
||||
{
|
||||
originalKey: 'selectionEnabled',
|
||||
targetKey: 'feature.selection.enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'filterList',
|
||||
targetKey: 'feature.selection.filter_list'
|
||||
},
|
||||
{
|
||||
originalKey: 'filterMode',
|
||||
targetKey: 'feature.selection.filter_mode'
|
||||
},
|
||||
{
|
||||
originalKey: 'isFollowToolbar',
|
||||
targetKey: 'feature.selection.follow_toolbar'
|
||||
},
|
||||
{
|
||||
originalKey: 'isRemeberWinSize',
|
||||
targetKey: 'feature.selection.remember_win_size'
|
||||
},
|
||||
{
|
||||
originalKey: 'triggerMode',
|
||||
targetKey: 'feature.selection.trigger_mode'
|
||||
},
|
||||
{
|
||||
originalKey: 'isCompact',
|
||||
targetKey: 'feature.selection.compact'
|
||||
},
|
||||
{
|
||||
originalKey: 'isAutoClose',
|
||||
targetKey: 'feature.selection.auto_close'
|
||||
},
|
||||
{
|
||||
originalKey: 'isAutoPin',
|
||||
targetKey: 'feature.selection.auto_pin'
|
||||
},
|
||||
{
|
||||
originalKey: 'actionWindowOpacity',
|
||||
targetKey: 'feature.selection.action_window_opacity'
|
||||
},
|
||||
{
|
||||
originalKey: 'actionItems',
|
||||
targetKey: 'feature.selection.action_items'
|
||||
}
|
||||
],
|
||||
nutstore: [
|
||||
{
|
||||
originalKey: 'nutstoreToken',
|
||||
targetKey: 'data.backup.nutstore.token'
|
||||
},
|
||||
{
|
||||
originalKey: 'nutstorePath',
|
||||
targetKey: 'data.backup.nutstore.path'
|
||||
},
|
||||
{
|
||||
originalKey: 'nutstoreAutoSync',
|
||||
targetKey: 'data.backup.nutstore.auto_sync'
|
||||
},
|
||||
{
|
||||
originalKey: 'nutstoreSyncInterval',
|
||||
targetKey: 'data.backup.nutstore.sync_interval'
|
||||
},
|
||||
{
|
||||
originalKey: 'nutstoreSyncState',
|
||||
targetKey: 'data.backup.nutstore.sync_state'
|
||||
},
|
||||
{
|
||||
originalKey: 'nutstoreSkipBackupFile',
|
||||
targetKey: 'data.backup.nutstore.skip_backup_file'
|
||||
}
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
originalKey: 'shortcuts.zoom_in',
|
||||
targetKey: 'shortcut.app.zoom_in'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.zoom_out',
|
||||
targetKey: 'shortcut.app.zoom_out'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.zoom_reset',
|
||||
targetKey: 'shortcut.app.zoom_reset'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.show_settings',
|
||||
targetKey: 'shortcut.app.show_settings'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.show_app',
|
||||
targetKey: 'shortcut.app.show_main_window'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.mini_window',
|
||||
targetKey: 'shortcut.app.show_mini_window'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.selection_assistant_toggle',
|
||||
targetKey: 'shortcut.selection.toggle_enabled'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.selection_assistant_select_text',
|
||||
targetKey: 'shortcut.selection.get_text'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.new_topic',
|
||||
targetKey: 'shortcut.topic.new'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.toggle_show_assistants',
|
||||
targetKey: 'shortcut.app.toggle_show_assistants'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.copy_last_message',
|
||||
targetKey: 'shortcut.chat.copy_last_message'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.search_message_in_chat',
|
||||
targetKey: 'shortcut.chat.search_message'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.search_message',
|
||||
targetKey: 'shortcut.app.search_message'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.clear_topic',
|
||||
targetKey: 'shortcut.chat.clear'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.toggle_new_context',
|
||||
targetKey: 'shortcut.chat.toggle_new_context'
|
||||
},
|
||||
{
|
||||
originalKey: 'shortcuts.exit_fullscreen',
|
||||
targetKey: 'shortcut.app.exit_fullscreen'
|
||||
}
|
||||
]
|
||||
} as const
|
||||
|
||||
// === AUTO-GENERATED CONTENT END ===
|
||||
|
||||
/**
|
||||
* 映射统计:
|
||||
* - ElectronStore项: 1
|
||||
* - Redux Store项: 175
|
||||
* - Redux分类: settings, selectionStore, nutstore, shortcuts
|
||||
* - 总配置项: 176
|
||||
*
|
||||
* 使用说明:
|
||||
* 1. ElectronStore读取: configManager.get(mapping.originalKey)
|
||||
* 2. Redux读取: 需要解析嵌套路径 reduxData[category][originalKey路径]
|
||||
* 3. 默认值: 从defaultPreferences.default[mapping.targetKey]获取
|
||||
*/
|
||||
@ -1,642 +0,0 @@
|
||||
import { dbService } from '@data/db/DbService'
|
||||
import { preferenceTable } from '@data/db/schemas/preference'
|
||||
import { loggerService } from '@logger'
|
||||
import { DefaultPreferences } from '@shared/data/preference/preferenceSchemas'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
|
||||
import { configManager } from '../../../../services/ConfigManager'
|
||||
import type { DataRefactorMigrateService } from '../DataRefactorMigrateService'
|
||||
import { ELECTRON_STORE_MAPPINGS, REDUX_STORE_MAPPINGS } from './PreferencesMappings'
|
||||
|
||||
const logger = loggerService.withContext('PreferencesMigrator')
|
||||
|
||||
export interface MigrationItem {
|
||||
originalKey: string
|
||||
targetKey: string
|
||||
type: string
|
||||
defaultValue: any
|
||||
source: 'electronStore' | 'redux'
|
||||
sourceCategory?: string // Optional for electronStore
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
success: boolean
|
||||
migratedCount: number
|
||||
errors: Array<{
|
||||
key: string
|
||||
error: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface PreparedMigrationData {
|
||||
targetKey: string
|
||||
value: any
|
||||
source: 'electronStore' | 'redux'
|
||||
originalKey: string
|
||||
sourceCategory?: string
|
||||
}
|
||||
|
||||
export interface BatchMigrationResult {
|
||||
newPreferences: PreparedMigrationData[]
|
||||
updatedPreferences: PreparedMigrationData[]
|
||||
skippedCount: number
|
||||
preparationErrors: Array<{
|
||||
key: string
|
||||
error: string
|
||||
}>
|
||||
}
|
||||
|
||||
export class PreferencesMigrator {
|
||||
private db = dbService.getDb()
|
||||
private migrateService: DataRefactorMigrateService
|
||||
|
||||
constructor(migrateService: DataRefactorMigrateService) {
|
||||
this.migrateService = migrateService
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute preferences migration from all sources using batch operations and transactions
|
||||
*/
|
||||
async migrate(onProgress?: (progress: number, message: string) => void): Promise<MigrationResult> {
|
||||
logger.info('Starting preferences migration with batch operations')
|
||||
|
||||
const result: MigrationResult = {
|
||||
success: true,
|
||||
migratedCount: 0,
|
||||
errors: []
|
||||
}
|
||||
|
||||
try {
|
||||
// Phase 1: Prepare all migration data in memory (50% of progress)
|
||||
onProgress?.(10, 'Loading migration items...')
|
||||
const migrationItems = await this.loadMigrationItems()
|
||||
logger.info(`Found ${migrationItems.length} items to migrate`)
|
||||
|
||||
onProgress?.(25, 'Preparing migration data...')
|
||||
const batchResult = await this.prepareMigrationData(migrationItems, (progress) => {
|
||||
// Map preparation progress to 25-50% of total progress
|
||||
const totalProgress = 25 + Math.floor(progress * 0.25)
|
||||
onProgress?.(totalProgress, 'Preparing migration data...')
|
||||
})
|
||||
|
||||
// Add preparation errors to result
|
||||
result.errors.push(...batchResult.preparationErrors)
|
||||
|
||||
if (batchResult.preparationErrors.length > 0) {
|
||||
logger.warn('Some items failed during preparation', {
|
||||
errorCount: batchResult.preparationErrors.length
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 2: Execute batch migration in transaction (50% of progress)
|
||||
onProgress?.(50, 'Executing batch migration...')
|
||||
|
||||
const totalOperations = batchResult.newPreferences.length + batchResult.updatedPreferences.length
|
||||
if (totalOperations > 0) {
|
||||
try {
|
||||
await this.executeBatchMigration(batchResult, (progress) => {
|
||||
// Map execution progress to 50-90% of total progress
|
||||
const totalProgress = 50 + Math.floor(progress * 0.4)
|
||||
onProgress?.(totalProgress, 'Executing batch migration...')
|
||||
})
|
||||
|
||||
result.migratedCount = totalOperations
|
||||
logger.info('Batch migration completed successfully', {
|
||||
newPreferences: batchResult.newPreferences.length,
|
||||
updatedPreferences: batchResult.updatedPreferences.length,
|
||||
skippedCount: batchResult.skippedCount
|
||||
})
|
||||
} catch (batchError) {
|
||||
logger.error('Batch migration transaction failed - all changes rolled back', batchError as Error)
|
||||
result.success = false
|
||||
result.errors.push({
|
||||
key: 'batch_migration',
|
||||
error: `Transaction failed: ${batchError instanceof Error ? batchError.message : String(batchError)}`
|
||||
})
|
||||
// Note: No need to manually rollback - transaction handles this automatically
|
||||
}
|
||||
} else {
|
||||
logger.info('No preferences to migrate')
|
||||
}
|
||||
|
||||
onProgress?.(100, 'Migration completed')
|
||||
|
||||
// Set success based on whether we had any critical errors
|
||||
result.success = result.errors.length === 0
|
||||
|
||||
logger.info('Preferences migration completed', {
|
||||
migratedCount: result.migratedCount,
|
||||
errorCount: result.errors.length,
|
||||
skippedCount: batchResult.skippedCount
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Preferences migration failed', error as Error)
|
||||
result.success = false
|
||||
result.errors.push({
|
||||
key: 'global',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Load migration items from generated mapping relationships
|
||||
* This uses the auto-generated PreferencesMappings.ts file
|
||||
*/
|
||||
private async loadMigrationItems(): Promise<MigrationItem[]> {
|
||||
logger.info('Loading migration items from generated mappings')
|
||||
const items: MigrationItem[] = []
|
||||
|
||||
// Process ElectronStore mappings - no sourceCategory needed
|
||||
ELECTRON_STORE_MAPPINGS.forEach((mapping) => {
|
||||
const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null
|
||||
items.push({
|
||||
originalKey: mapping.originalKey,
|
||||
targetKey: mapping.targetKey,
|
||||
type: 'unknown', // Type will be inferred from defaultValue during conversion
|
||||
defaultValue,
|
||||
source: 'electronStore'
|
||||
})
|
||||
})
|
||||
|
||||
// Process Redux mappings
|
||||
Object.entries(REDUX_STORE_MAPPINGS).forEach(([category, mappings]) => {
|
||||
mappings.forEach((mapping) => {
|
||||
const defaultValue = DefaultPreferences.default[mapping.targetKey] ?? null
|
||||
items.push({
|
||||
originalKey: mapping.originalKey, // May contain nested paths like "codeEditor.enabled"
|
||||
targetKey: mapping.targetKey,
|
||||
sourceCategory: category,
|
||||
type: 'unknown', // Type will be inferred from defaultValue during conversion
|
||||
defaultValue,
|
||||
source: 'redux'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
logger.info('Successfully loaded migration items from generated mappings', {
|
||||
totalItems: items.length,
|
||||
electronStoreItems: items.filter((i) => i.source === 'electronStore').length,
|
||||
reduxItems: items.filter((i) => i.source === 'redux').length
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare all migration data in memory before database operations
|
||||
* This phase reads all source data and performs conversions/validations
|
||||
*/
|
||||
private async prepareMigrationData(
|
||||
migrationItems: MigrationItem[],
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<BatchMigrationResult> {
|
||||
logger.info('Starting migration data preparation', { itemCount: migrationItems.length })
|
||||
|
||||
const batchResult: BatchMigrationResult = {
|
||||
newPreferences: [],
|
||||
updatedPreferences: [],
|
||||
skippedCount: 0,
|
||||
preparationErrors: []
|
||||
}
|
||||
|
||||
// Get existing preferences to determine which are new vs updated
|
||||
const existingPreferences = await this.getExistingPreferences()
|
||||
const existingKeys = new Set(existingPreferences.map((p) => p.key))
|
||||
|
||||
// Process each migration item
|
||||
for (let i = 0; i < migrationItems.length; i++) {
|
||||
const item = migrationItems[i]
|
||||
|
||||
try {
|
||||
// Read original value from source
|
||||
let originalValue: any
|
||||
if (item.source === 'electronStore') {
|
||||
originalValue = await this.readFromElectronStore(item.originalKey)
|
||||
} else if (item.source === 'redux') {
|
||||
if (!item.sourceCategory) {
|
||||
throw new Error(`Redux source requires sourceCategory for item: ${item.originalKey}`)
|
||||
}
|
||||
originalValue = await this.readFromReduxPersist(item.sourceCategory, item.originalKey)
|
||||
} else {
|
||||
throw new Error(`Unknown source: ${item.source}`)
|
||||
}
|
||||
|
||||
// Determine value to migrate
|
||||
let valueToMigrate = originalValue
|
||||
let shouldSkip = false
|
||||
|
||||
if (originalValue === undefined || originalValue === null) {
|
||||
if (item.defaultValue !== null && item.defaultValue !== undefined) {
|
||||
valueToMigrate = item.defaultValue
|
||||
logger.debug('Using default value for preparation', {
|
||||
targetKey: item.targetKey,
|
||||
source: item.source,
|
||||
originalKey: item.originalKey
|
||||
})
|
||||
} else {
|
||||
shouldSkip = true
|
||||
batchResult.skippedCount++
|
||||
logger.debug('Skipping item - no data and no meaningful default', {
|
||||
targetKey: item.targetKey,
|
||||
source: item.source,
|
||||
originalKey: item.originalKey
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!shouldSkip) {
|
||||
// Convert value to appropriate type
|
||||
const convertedValue = this.convertValue(valueToMigrate, item.type)
|
||||
|
||||
// Create prepared migration data
|
||||
const preparedData: PreparedMigrationData = {
|
||||
targetKey: item.targetKey,
|
||||
value: convertedValue,
|
||||
source: item.source,
|
||||
originalKey: item.originalKey,
|
||||
sourceCategory: item.sourceCategory
|
||||
}
|
||||
|
||||
// Categorize as new or updated
|
||||
if (existingKeys.has(item.targetKey)) {
|
||||
batchResult.updatedPreferences.push(preparedData)
|
||||
} else {
|
||||
batchResult.newPreferences.push(preparedData)
|
||||
}
|
||||
|
||||
logger.debug('Prepared migration data', {
|
||||
targetKey: item.targetKey,
|
||||
isUpdate: existingKeys.has(item.targetKey),
|
||||
source: item.source
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to prepare migration item', { item, error })
|
||||
batchResult.preparationErrors.push({
|
||||
key: item.originalKey,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
// Report progress
|
||||
const progress = Math.floor(((i + 1) / migrationItems.length) * 100)
|
||||
onProgress?.(progress)
|
||||
}
|
||||
|
||||
logger.info('Migration data preparation completed', {
|
||||
newPreferences: batchResult.newPreferences.length,
|
||||
updatedPreferences: batchResult.updatedPreferences.length,
|
||||
skippedCount: batchResult.skippedCount,
|
||||
errorCount: batchResult.preparationErrors.length
|
||||
})
|
||||
|
||||
return batchResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all existing preferences from database to determine new vs updated items
|
||||
*/
|
||||
private async getExistingPreferences(): Promise<Array<{ key: string; value: any }>> {
|
||||
try {
|
||||
const preferences = await this.db
|
||||
.select({
|
||||
key: preferenceTable.key,
|
||||
value: preferenceTable.value
|
||||
})
|
||||
.from(preferenceTable)
|
||||
.where(eq(preferenceTable.scope, 'default'))
|
||||
|
||||
logger.debug('Loaded existing preferences', { count: preferences.length })
|
||||
return preferences
|
||||
} catch (error) {
|
||||
logger.error('Failed to load existing preferences', error as Error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute batch migration using database transaction with bulk operations
|
||||
*/
|
||||
private async executeBatchMigration(
|
||||
batchData: BatchMigrationResult,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<void> {
|
||||
logger.info('Starting batch migration execution', {
|
||||
newCount: batchData.newPreferences.length,
|
||||
updateCount: batchData.updatedPreferences.length
|
||||
})
|
||||
|
||||
// Validate batch data before starting transaction
|
||||
this.validateBatchData(batchData)
|
||||
|
||||
await this.db.transaction(async (tx) => {
|
||||
const scope = 'default'
|
||||
const timestamp = Date.now()
|
||||
let completedOperations = 0
|
||||
const totalOperations = batchData.newPreferences.length + batchData.updatedPreferences.length
|
||||
|
||||
// Batch insert new preferences
|
||||
if (batchData.newPreferences.length > 0) {
|
||||
logger.debug('Executing batch insert for new preferences', { count: batchData.newPreferences.length })
|
||||
|
||||
const insertValues = batchData.newPreferences.map((item) => ({
|
||||
scope,
|
||||
key: item.targetKey,
|
||||
value: item.value,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp
|
||||
}))
|
||||
|
||||
await tx.insert(preferenceTable).values(insertValues)
|
||||
|
||||
completedOperations += batchData.newPreferences.length
|
||||
const progress = Math.floor((completedOperations / totalOperations) * 100)
|
||||
onProgress?.(progress)
|
||||
|
||||
logger.info('Batch insert completed', { insertedCount: batchData.newPreferences.length })
|
||||
}
|
||||
|
||||
// Batch update existing preferences
|
||||
if (batchData.updatedPreferences.length > 0) {
|
||||
logger.debug('Executing batch updates for existing preferences', { count: batchData.updatedPreferences.length })
|
||||
|
||||
// Execute updates in batches to avoid SQL limitations
|
||||
const BATCH_SIZE = 50
|
||||
const updateBatches = this.chunkArray(batchData.updatedPreferences, BATCH_SIZE)
|
||||
|
||||
for (const batch of updateBatches) {
|
||||
// Use Promise.all to execute updates in parallel within the transaction
|
||||
await Promise.all(
|
||||
batch.map((item) =>
|
||||
tx
|
||||
.update(preferenceTable)
|
||||
.set({
|
||||
value: item.value,
|
||||
updatedAt: timestamp
|
||||
})
|
||||
.where(and(eq(preferenceTable.scope, scope), eq(preferenceTable.key, item.targetKey)))
|
||||
)
|
||||
)
|
||||
|
||||
completedOperations += batch.length
|
||||
const progress = Math.floor((completedOperations / totalOperations) * 100)
|
||||
onProgress?.(progress)
|
||||
}
|
||||
|
||||
logger.info('Batch updates completed', { updatedCount: batchData.updatedPreferences.length })
|
||||
}
|
||||
|
||||
logger.info('Transaction completed successfully', {
|
||||
totalOperations: completedOperations,
|
||||
newPreferences: batchData.newPreferences.length,
|
||||
updatedPreferences: batchData.updatedPreferences.length
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate batch data before executing migration
|
||||
*/
|
||||
private validateBatchData(batchData: BatchMigrationResult): void {
|
||||
const allData = [...batchData.newPreferences, ...batchData.updatedPreferences]
|
||||
|
||||
// Check for duplicate target keys
|
||||
const targetKeys = allData.map((item) => item.targetKey)
|
||||
const duplicateKeys = targetKeys.filter((key, index) => targetKeys.indexOf(key) !== index)
|
||||
|
||||
if (duplicateKeys.length > 0) {
|
||||
throw new Error(`Duplicate target keys found in migration data: ${duplicateKeys.join(', ')}`)
|
||||
}
|
||||
|
||||
// Validate each item has required fields
|
||||
for (const item of allData) {
|
||||
if (!item.targetKey || item.targetKey.trim() === '') {
|
||||
throw new Error(`Invalid targetKey found: '${item.targetKey}'`)
|
||||
}
|
||||
|
||||
if (item.value === undefined) {
|
||||
throw new Error(`Undefined value for targetKey: '${item.targetKey}'`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('Batch data validation passed', {
|
||||
totalItems: allData.length,
|
||||
uniqueKeys: targetKeys.length
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Split array into chunks of specified size for batch processing
|
||||
*/
|
||||
private chunkArray<T>(array: T[], chunkSize: number): T[][] {
|
||||
const chunks: T[][] = []
|
||||
for (let i = 0; i < array.length; i += chunkSize) {
|
||||
chunks.push(array.slice(i, i + chunkSize))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* Read value from ElectronStore (via ConfigManager)
|
||||
*/
|
||||
private async readFromElectronStore(key: string): Promise<any> {
|
||||
try {
|
||||
return configManager.get(key)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read from ElectronStore', { key, error })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read value from Redux persist data with support for nested paths
|
||||
*/
|
||||
private async readFromReduxPersist(category: string, key: string): Promise<any> {
|
||||
try {
|
||||
// Get cached Redux data from migrate service
|
||||
const reduxData = this.migrateService?.getReduxData()
|
||||
|
||||
if (!reduxData) {
|
||||
logger.warn('No Redux persist data available in cache', { category, key })
|
||||
return undefined
|
||||
}
|
||||
|
||||
logger.debug('Reading from cached Redux persist data', {
|
||||
category,
|
||||
key,
|
||||
availableCategories: Object.keys(reduxData),
|
||||
isNestedKey: key.includes('.')
|
||||
})
|
||||
|
||||
// Get the category data from Redux persist cache
|
||||
const categoryData = reduxData[category]
|
||||
if (!categoryData) {
|
||||
logger.debug('Category not found in Redux persist data', {
|
||||
category,
|
||||
availableCategories: Object.keys(reduxData)
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Redux persist usually stores data as JSON strings
|
||||
let parsedCategoryData
|
||||
try {
|
||||
parsedCategoryData = typeof categoryData === 'string' ? JSON.parse(categoryData) : categoryData
|
||||
} catch (parseError) {
|
||||
logger.warn('Failed to parse Redux persist category data', {
|
||||
category,
|
||||
categoryData: typeof categoryData,
|
||||
parseError
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Handle nested paths (e.g., "codeEditor.enabled")
|
||||
let value
|
||||
if (key.includes('.')) {
|
||||
// Parse nested path
|
||||
const keyPath = key.split('.')
|
||||
let current = parsedCategoryData
|
||||
|
||||
logger.debug('Parsing nested key path', {
|
||||
category,
|
||||
key,
|
||||
keyPath,
|
||||
rootDataKeys: current ? Object.keys(current) : []
|
||||
})
|
||||
|
||||
for (const pathSegment of keyPath) {
|
||||
if (current && typeof current === 'object' && !Array.isArray(current)) {
|
||||
current = current[pathSegment]
|
||||
logger.debug('Navigated to path segment', {
|
||||
pathSegment,
|
||||
foundValue: current !== undefined,
|
||||
valueType: typeof current
|
||||
})
|
||||
} else {
|
||||
logger.debug('Failed to navigate nested path - invalid structure', {
|
||||
pathSegment,
|
||||
currentType: typeof current,
|
||||
isArray: Array.isArray(current)
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
value = current
|
||||
} else {
|
||||
// Direct field access (e.g., "theme")
|
||||
value = parsedCategoryData[key]
|
||||
}
|
||||
|
||||
if (value !== undefined) {
|
||||
logger.debug('Successfully read from Redux persist cache', {
|
||||
category,
|
||||
key,
|
||||
value,
|
||||
valueType: typeof value,
|
||||
isNested: key.includes('.')
|
||||
})
|
||||
} else {
|
||||
logger.debug('Key not found in Redux persist data', {
|
||||
category,
|
||||
key,
|
||||
availableKeys: parsedCategoryData ? Object.keys(parsedCategoryData) : []
|
||||
})
|
||||
}
|
||||
|
||||
return value
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read from Redux persist cache', { category, key, error })
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert value to the specified type
|
||||
*/
|
||||
private convertValue(value: any, targetType: string): any {
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
switch (targetType) {
|
||||
case 'boolean':
|
||||
return this.toBoolean(value)
|
||||
case 'string':
|
||||
return this.toString(value)
|
||||
case 'number':
|
||||
return this.toNumber(value)
|
||||
case 'array':
|
||||
case 'unknown[]':
|
||||
return this.toArray(value)
|
||||
case 'object':
|
||||
case 'Record<string, unknown>':
|
||||
return this.toObject(value)
|
||||
default:
|
||||
return value
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Type conversion failed, using original value', { value, targetType, error })
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private toBoolean(value: any): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'string') {
|
||||
const lower = value.toLowerCase()
|
||||
return lower === 'true' || lower === '1' || lower === 'yes'
|
||||
}
|
||||
if (typeof value === 'number') return value !== 0
|
||||
return Boolean(value)
|
||||
}
|
||||
|
||||
private toString(value: any): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
private toNumber(value: any): number {
|
||||
if (typeof value === 'number') return value
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseFloat(value)
|
||||
return isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? 1 : 0
|
||||
return 0
|
||||
}
|
||||
|
||||
private toArray(value: any): any[] {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed : [value]
|
||||
} catch {
|
||||
return [value]
|
||||
}
|
||||
}
|
||||
return [value]
|
||||
}
|
||||
|
||||
private toObject(value: any): Record<string, any> {
|
||||
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : { value }
|
||||
} catch {
|
||||
return { value }
|
||||
}
|
||||
}
|
||||
return { value }
|
||||
}
|
||||
}
|
||||
@ -195,17 +195,6 @@ if (!app.requestSingleInstanceLock()) {
|
||||
// Initialize CacheService
|
||||
await cacheService.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****************/
|
||||
|
||||
// Record current version for tracking
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Cherry Studio - Data Refactor Migration</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/dataRefactorMigrate/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;
|
||||
}
|
||||
|
||||
#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>
|
||||
@ -1,62 +0,0 @@
|
||||
<!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>
|
||||
@ -1,562 +0,0 @@
|
||||
import { Button } from '@cherrystudio/ui'
|
||||
import { getToastUtilities } from '@renderer/components/TopView/toast'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { IpcChannel } from '@shared/IpcChannel'
|
||||
import { Progress, Space, Steps } from 'antd'
|
||||
import { AlertTriangle, CheckCircle, Database, Loader2, Rocket } from 'lucide-react'
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const logger = loggerService.withContext('MigrateApp')
|
||||
|
||||
type MigrationStage =
|
||||
| 'introduction' // Introduction phase - user can cancel
|
||||
| 'backup_required' // Backup required - show backup requirement
|
||||
| 'backup_progress' // Backup in progress - user is backing up
|
||||
| 'backup_confirmed' // Backup confirmed - ready to migrate
|
||||
| 'migration' // Migration in progress - cannot cancel
|
||||
| 'completed' // Completed - restart app
|
||||
| 'error' // Error - recovery options
|
||||
|
||||
interface MigrationProgress {
|
||||
stage: MigrationStage
|
||||
progress: number
|
||||
total: number
|
||||
message: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const MigrateApp: React.FC = () => {
|
||||
const [progress, setProgress] = useState<MigrationProgress>({
|
||||
stage: 'introduction',
|
||||
progress: 0,
|
||||
total: 100,
|
||||
message: 'Ready to start data migration'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
window.toast = getToastUtilities()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for progress updates
|
||||
const handleProgress = (_: any, progressData: MigrationProgress) => {
|
||||
setProgress(progressData)
|
||||
}
|
||||
|
||||
window.electron.ipcRenderer.on(IpcChannel.DataMigrateProgress, handleProgress)
|
||||
|
||||
// Request initial progress
|
||||
window.electron.ipcRenderer
|
||||
.invoke(IpcChannel.DataMigrate_GetProgress)
|
||||
.then((initialProgress: MigrationProgress) => {
|
||||
if (initialProgress) {
|
||||
setProgress(initialProgress)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
window.electron.ipcRenderer.removeAllListeners(IpcChannel.DataMigrateProgress)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const currentStep = useMemo(() => {
|
||||
switch (progress.stage) {
|
||||
case 'introduction':
|
||||
return 0
|
||||
case 'backup_required':
|
||||
case 'backup_progress':
|
||||
case 'backup_confirmed':
|
||||
return 1
|
||||
case 'migration':
|
||||
return 2
|
||||
case 'completed':
|
||||
return 3
|
||||
case 'error':
|
||||
return -1 // Error state - will be handled separately
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}, [progress.stage])
|
||||
|
||||
const stepStatus = useMemo(() => {
|
||||
if (progress.stage === 'error') {
|
||||
return 'error'
|
||||
}
|
||||
return 'process'
|
||||
}, [progress.stage])
|
||||
|
||||
/**
|
||||
* Extract Redux persist data from localStorage and send to main process
|
||||
*/
|
||||
const extractAndSendReduxData = async () => {
|
||||
try {
|
||||
logger.info('Extracting Redux persist data for migration...')
|
||||
|
||||
// Get the Redux persist key (this should match the key used in store configuration)
|
||||
const persistKey = 'persist:cherry-studio'
|
||||
|
||||
// Read the persisted data from localStorage
|
||||
const persistedDataString = localStorage.getItem(persistKey)
|
||||
|
||||
if (!persistedDataString) {
|
||||
logger.warn('No Redux persist data found in localStorage', { persistKey })
|
||||
// Send empty data to indicate no Redux data available
|
||||
await window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_SendReduxData, null)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the persisted data
|
||||
const persistedData = JSON.parse(persistedDataString)
|
||||
|
||||
logger.info('Found Redux persist data:', {
|
||||
keys: Object.keys(persistedData),
|
||||
hasData: !!persistedData,
|
||||
dataSize: persistedDataString.length,
|
||||
persistKey
|
||||
})
|
||||
|
||||
// Send the Redux data to main process for migration
|
||||
const result = await window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_SendReduxData, persistedData)
|
||||
|
||||
if (result?.success) {
|
||||
logger.info('Successfully sent Redux data to main process for migration')
|
||||
} else {
|
||||
logger.warn('Failed to send Redux data to main process', { result })
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error extracting Redux persist data', error as Error)
|
||||
// Send null to indicate extraction failed
|
||||
await window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_SendReduxData, null)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const handleProceedToBackup = () => {
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_ProceedToBackup)
|
||||
}
|
||||
|
||||
const handleStartMigration = async () => {
|
||||
try {
|
||||
// First, extract Redux persist data and send to main process
|
||||
await extractAndSendReduxData()
|
||||
|
||||
// Then start the migration process
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_StartMigration)
|
||||
} catch (error) {
|
||||
logger.error('Failed to extract Redux data for migration', error as Error)
|
||||
// Still proceed with migration even if Redux data extraction fails
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_StartMigration)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestartApp = () => {
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_RestartApp)
|
||||
}
|
||||
|
||||
const handleCloseWindow = () => {
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_CloseWindow)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_Cancel)
|
||||
}
|
||||
|
||||
const handleShowBackupDialog = () => {
|
||||
// Open the main window backup dialog
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_ShowBackupDialog)
|
||||
}
|
||||
|
||||
const handleBackupCompleted = () => {
|
||||
// Notify the main process that backup is completed
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_BackupCompleted)
|
||||
}
|
||||
|
||||
const handleRetryMigration = () => {
|
||||
window.electron.ipcRenderer.invoke(IpcChannel.DataMigrate_RetryMigration)
|
||||
}
|
||||
|
||||
const getProgressColor = () => {
|
||||
switch (progress.stage) {
|
||||
case 'completed':
|
||||
return 'var(--color-primary)'
|
||||
case 'error':
|
||||
return '#ff4d4f'
|
||||
case 'backup_confirmed':
|
||||
return 'var(--color-primary)'
|
||||
default:
|
||||
return 'var(--color-primary)'
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentStepIcon = () => {
|
||||
switch (progress.stage) {
|
||||
case 'introduction':
|
||||
return <Rocket size={48} color="var(--color-primary)" />
|
||||
case 'backup_required':
|
||||
case 'backup_progress':
|
||||
return <Database size={48} color="var(--color-primary)" />
|
||||
case 'backup_confirmed':
|
||||
return <CheckCircle size={48} color="var(--color-primary)" />
|
||||
case 'migration':
|
||||
return (
|
||||
<SpinningIcon>
|
||||
<Loader2 size={48} color="var(--color-primary)" />
|
||||
</SpinningIcon>
|
||||
)
|
||||
case 'completed':
|
||||
return <CheckCircle size={48} color="var(--color-primary)" />
|
||||
case 'error':
|
||||
return <AlertTriangle size={48} color="#ff4d4f" />
|
||||
default:
|
||||
return <Rocket size={48} color="var(--color-primary)" />
|
||||
}
|
||||
}
|
||||
|
||||
const renderActionButtons = () => {
|
||||
switch (progress.stage) {
|
||||
case 'introduction':
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Spacer />
|
||||
<Button onClick={handleProceedToBackup}>下一步</Button>
|
||||
</>
|
||||
)
|
||||
case 'backup_required':
|
||||
return (
|
||||
<>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Spacer />
|
||||
<Button onClick={handleBackupCompleted}>我已备份,开始迁移</Button>
|
||||
<Button onClick={handleShowBackupDialog}>创建备份</Button>
|
||||
</>
|
||||
)
|
||||
case 'backup_confirmed':
|
||||
return (
|
||||
<ButtonRow>
|
||||
<Button onClick={handleCancel}>取消</Button>
|
||||
<Space>
|
||||
<Button onClick={handleStartMigration}>开始迁移</Button>
|
||||
</Space>
|
||||
</ButtonRow>
|
||||
)
|
||||
case 'migration':
|
||||
return (
|
||||
<ButtonRow>
|
||||
<div></div>
|
||||
<Button disabled>迁移进行中...</Button>
|
||||
</ButtonRow>
|
||||
)
|
||||
case 'completed':
|
||||
return (
|
||||
<ButtonRow>
|
||||
<div></div>
|
||||
<Button onClick={handleRestartApp}>重启应用</Button>
|
||||
</ButtonRow>
|
||||
)
|
||||
case 'error':
|
||||
return (
|
||||
<ButtonRow>
|
||||
<Button onClick={handleCloseWindow}>关闭应用</Button>
|
||||
<Space>
|
||||
<Button onClick={handleRetryMigration}>重新尝试</Button>
|
||||
</Space>
|
||||
</ButtonRow>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* Header */}
|
||||
<Header>
|
||||
<HeaderLogo src={AppLogo} />
|
||||
|
||||
<HeaderTitle>数据迁移向导</HeaderTitle>
|
||||
</Header>
|
||||
|
||||
{/* Main Content */}
|
||||
<MainContent>
|
||||
{/* Left Sidebar with Steps */}
|
||||
<LeftSidebar>
|
||||
<StepsContainer>
|
||||
<Steps
|
||||
direction="vertical"
|
||||
current={currentStep}
|
||||
status={stepStatus}
|
||||
size="small"
|
||||
items={[{ title: '介绍' }, { title: '备份' }, { title: '迁移' }, { title: '完成' }]}
|
||||
/>
|
||||
</StepsContainer>
|
||||
</LeftSidebar>
|
||||
|
||||
{/* Right Content Area */}
|
||||
<RightContent>
|
||||
<ContentArea>
|
||||
<InfoIcon>{getCurrentStepIcon()}</InfoIcon>
|
||||
|
||||
{progress.stage === 'introduction' && (
|
||||
<InfoCard>
|
||||
<InfoTitle>将数据迁移到新的架构中</InfoTitle>
|
||||
<InfoDescription>
|
||||
Cherry Studio对数据的存储和使用方式进行了重大重构,在新的架构下,效率和安全性将会得到极大提升。
|
||||
<br />
|
||||
<br />
|
||||
数据必须进行迁移,才能在新版本中使用。
|
||||
<br />
|
||||
<br />
|
||||
我们会指导你完成迁移,迁移过程不会损坏原来的数据,你随时可以取消迁移,并继续使用旧版本。
|
||||
</InfoDescription>
|
||||
{/* Debug button to test Redux data extraction */}
|
||||
<div style={{ marginTop: '24px', textAlign: 'center' }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await extractAndSendReduxData()
|
||||
alert('Redux数据提取成功!请查看应用日志。')
|
||||
} catch (error) {
|
||||
alert('Redux数据提取失败:' + (error as Error).message)
|
||||
}
|
||||
}}>
|
||||
测试Redux数据提取
|
||||
</Button>
|
||||
</div>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{progress.stage === 'backup_required' && (
|
||||
<InfoCard variant="warning">
|
||||
<InfoTitle>创建数据备份</InfoTitle>
|
||||
<InfoDescription style={{ textAlign: 'center' }}>
|
||||
迁移前必须创建数据备份以确保数据安全。请选择备份位置或确认已有最新备份。
|
||||
</InfoDescription>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{progress.stage === 'backup_progress' && (
|
||||
<InfoCard variant="warning">
|
||||
<InfoTitle>准备数据备份</InfoTitle>
|
||||
<InfoDescription style={{ textAlign: 'center' }}>请选择备份位置,保存后等待备份完成。</InfoDescription>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{progress.stage === 'backup_confirmed' && (
|
||||
<InfoCard variant="success">
|
||||
<InfoTitle>备份完成</InfoTitle>
|
||||
<InfoDescription style={{ textAlign: 'center' }}>
|
||||
数据备份已完成,现在可以安全地开始迁移。
|
||||
</InfoDescription>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{progress.stage === 'error' && (
|
||||
<InfoCard variant="error">
|
||||
<InfoTitle>迁移失败</InfoTitle>
|
||||
<InfoDescription>
|
||||
迁移过程遇到错误,您可以重新尝试或继续使用之前版本(原始数据完好保存)。
|
||||
<br />
|
||||
<br />
|
||||
错误信息:{progress.error}
|
||||
</InfoDescription>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{progress.stage === 'completed' && (
|
||||
<InfoCard variant="success">
|
||||
<InfoTitle>迁移完成</InfoTitle>
|
||||
<InfoDescription>数据已成功迁移,重启应用后即可正常使用。</InfoDescription>
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
{(progress.stage == 'backup_progress' || progress.stage == 'migration') && (
|
||||
<ProgressContainer>
|
||||
<Progress
|
||||
percent={progress.progress}
|
||||
strokeColor={getProgressColor()}
|
||||
trailColor="#f0f0f0"
|
||||
size="default"
|
||||
showInfo={true}
|
||||
/>
|
||||
</ProgressContainer>
|
||||
)}
|
||||
</ContentArea>
|
||||
</RightContent>
|
||||
</MainContent>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer>{renderActionButtons()}</Footer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #fff;
|
||||
`
|
||||
|
||||
const Header = styled.div`
|
||||
height: 48px;
|
||||
background: rgb(240, 240, 240);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
-webkit-app-region: drag;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
const HeaderTitle = styled.div`
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: black;
|
||||
margin-left: 12px;
|
||||
`
|
||||
|
||||
const HeaderLogo = styled.img`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
`
|
||||
|
||||
const MainContent = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
`
|
||||
|
||||
const LeftSidebar = styled.div`
|
||||
width: 150px;
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const StepsContainer = styled.div`
|
||||
padding: 32px 24px;
|
||||
flex: 1;
|
||||
|
||||
.ant-steps-item-process .ant-steps-item-icon {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.ant-steps-item-finish .ant-steps-item-icon {
|
||||
background-color: var(--color-primary-mute);
|
||||
border-color: var(--color-primary-mute);
|
||||
}
|
||||
|
||||
.ant-steps-item-finish .ant-steps-item-icon > .ant-steps-icon {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.ant-steps-item-process .ant-steps-item-icon > .ant-steps-icon {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ant-steps-item-wait .ant-steps-item-icon {
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
`
|
||||
|
||||
const RightContent = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const ContentArea = styled.div`
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* justify-content: center; */
|
||||
/* align-items: center; */
|
||||
/* margin: 0 auto; */
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
`
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
background: rgb(250, 250, 250);
|
||||
|
||||
height: 64px;
|
||||
|
||||
padding: 0 24px;
|
||||
gap: 16px;
|
||||
`
|
||||
|
||||
const Spacer = styled.div`
|
||||
flex: 1;
|
||||
`
|
||||
|
||||
const ProgressContainer = styled.div`
|
||||
margin: 32px 0;
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const ButtonRow = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
`
|
||||
|
||||
const InfoIcon = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
`
|
||||
|
||||
const InfoCard = styled.div<{ variant?: 'info' | 'warning' | 'success' | 'error' }>`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const InfoTitle = styled.div`
|
||||
margin-bottom: 32px;
|
||||
margin-top: 32px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const InfoDescription = styled.p`
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.68);
|
||||
line-height: 1.8;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
`
|
||||
|
||||
const SpinningIcon = styled.div`
|
||||
display: inline-block;
|
||||
animation: spin 2s linear infinite;
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default MigrateApp
|
||||
@ -1,13 +0,0 @@
|
||||
import '@ant-design/v5-patch-for-react-19'
|
||||
import '@renderer/assets/styles/index.css'
|
||||
|
||||
import { loggerService } from '@logger'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import MigrateApp from './MigrateApp'
|
||||
|
||||
loggerService.initWindowSource('MigrateApp')
|
||||
|
||||
const root = createRoot(document.getElementById('root') as HTMLElement)
|
||||
|
||||
root.render(<MigrateApp />)
|
||||
@ -1,272 +0,0 @@
|
||||
# 数据重构项目测试窗口
|
||||
|
||||
专用于测试数据重构项目各项功能的独立测试窗口系统,包括 PreferenceService、CacheService、DataApiService 和相关 React hooks。
|
||||
|
||||
## 🎯 当前实现
|
||||
|
||||
✅ **已完成的功能**:
|
||||
|
||||
- 专用的测试窗口 (DataRefactorTestWindow)
|
||||
- **双窗口启动**:应用启动时会同时打开主窗口和两个测试窗口
|
||||
- **跨窗口同步测试**:两个测试窗口可以相互验证偏好设置的实时同步
|
||||
- **实时UI联动**:主题、语言、缩放等偏好设置变化会立即反映在UI上
|
||||
- **🎛️ Slider联动测试**:多个交互式滑块控制数值类型偏好设置,支持跨窗口实时同步
|
||||
- **多源窗口编号**:支持URL参数、窗口标题、窗口名称等多种方式确定窗口编号
|
||||
- 完整的测试界面,包含4个专业测试组件
|
||||
- 自动窗口定位,避免重叠
|
||||
- 窗口编号标识,便于区分
|
||||
|
||||
## 测试组件
|
||||
|
||||
## PreferenceService 测试模块
|
||||
|
||||
### 1. PreferenceService 基础测试
|
||||
|
||||
- 直接测试服务层API:get, set, getCachedValue, isCached, preload, getMultiple
|
||||
- 支持各种数据类型:字符串、数字、布尔值、JSON对象
|
||||
- 实时显示操作结果
|
||||
|
||||
### 2. usePreference Hook 测试
|
||||
|
||||
- 测试单个偏好设置的React hooks
|
||||
- 支持的测试键:
|
||||
- `app.theme.mode` - 主题模式
|
||||
- `app.language` - 语言设置
|
||||
- `app.spell_check.enabled` - 拼写检查
|
||||
- `app.zoom_factor` - 缩放因子 (🎛️ 支持Slider)
|
||||
- `chat.message.font_size` - 消息字体大小 (🎛️ 支持Slider)
|
||||
- `feature.selection.action_window_opacity` - 选择窗口透明度 (🎛️ 支持Slider)
|
||||
- 实时值更新和类型转换
|
||||
- **Slider联动控制**:数值类型偏好设置提供交互式滑块,支持实时拖拽调整
|
||||
|
||||
### 3. usePreferences 批量操作测试
|
||||
|
||||
- 测试多个偏好设置的批量管理
|
||||
- 5种预设场景:基础设置、UI设置、用户设置、🎛️数值设置、自定义组合
|
||||
- **🎛️ 数值设置场景**:专门的Slider联动控制区域,包含缩放、字体、选择窗口透明度三个滑块
|
||||
- 批量更新功能,支持JSON格式输入
|
||||
- 快速切换操作
|
||||
|
||||
### 4. Hook 高级功能测试
|
||||
|
||||
- 预加载机制测试
|
||||
- 订阅机制验证
|
||||
- 缓存管理测试
|
||||
- 性能测试
|
||||
- 多个hook实例同步测试
|
||||
|
||||
## CacheService 测试模块
|
||||
|
||||
### 1. CacheService 直接API测试
|
||||
|
||||
- **三层缓存架构测试**:Memory cache、Shared cache、Persist cache
|
||||
- **基础操作**: get, set, has, delete 方法的完整测试
|
||||
- **TTL支持**: 可配置的过期时间测试(2s、5s、10s)
|
||||
- **跨窗口同步**: Shared cache 和 Persist cache 的实时同步验证
|
||||
- **数据类型支持**: 字符串、数字、对象、数组等多种数据类型
|
||||
- **性能优化**: 显示操作计数和自动刷新机制
|
||||
|
||||
### 2. Cache Hooks 基础测试
|
||||
|
||||
- **useCache Hook**: 测试内存缓存的React集成
|
||||
- 默认值自动设置
|
||||
- 实时值更新和类型安全
|
||||
- Hook生命周期管理
|
||||
- **useSharedCache Hook**: 测试跨窗口缓存同步
|
||||
- 跨窗口实时同步验证
|
||||
- 广播机制测试
|
||||
- 并发更新处理
|
||||
- **usePersistCache Hook**: 测试持久化缓存
|
||||
- 类型安全的预定义Schema
|
||||
- localStorage持久化
|
||||
- 默认值回退机制
|
||||
- **数据类型测试**:
|
||||
- 数字类型滑块控制
|
||||
- 复杂对象结构更新
|
||||
- 实时渲染统计
|
||||
|
||||
### 3. Cache 高级功能测试
|
||||
|
||||
- **TTL过期机制**:
|
||||
- 实时倒计时进度条
|
||||
- 自动过期验证
|
||||
- 懒加载清理机制
|
||||
- **Hook引用保护**:
|
||||
- 活跃Hook的key删除保护
|
||||
- 引用计数验证
|
||||
- 错误处理测试
|
||||
- **深度相等性优化**:
|
||||
- 相同引用跳过测试
|
||||
- 相同内容深度比较
|
||||
- 性能优化验证
|
||||
- **性能测试**:
|
||||
- 快速更新测试(100次/秒)
|
||||
- 订阅触发统计
|
||||
- 渲染次数监控
|
||||
- **多Hook同步**:
|
||||
- 同一key的多个hook实例
|
||||
- 跨缓存类型同步测试
|
||||
|
||||
### 4. Cache 压力测试
|
||||
|
||||
- **快速操作测试**:
|
||||
- 1000次操作/10秒高频测试
|
||||
- 每秒操作数统计
|
||||
- 错误率监控
|
||||
- **并发更新测试**:
|
||||
- 多个Hook同时更新
|
||||
- 跨窗口并发处理
|
||||
- 数据一致性验证
|
||||
- **大数据测试**:
|
||||
- 10KB、100KB、1MB对象存储
|
||||
- 内存使用估算
|
||||
- 存储限制警告
|
||||
- **存储限制测试**:
|
||||
- localStorage容量测试
|
||||
- 缓存大小监控
|
||||
- 性能影响评估
|
||||
|
||||
## 启动方式
|
||||
|
||||
**自动启动**:应用正常启动时会自动创建两个测试窗口,窗口会自动错位显示避免重叠
|
||||
|
||||
**手动启动**:
|
||||
|
||||
```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/
|
||||
# PreferenceService 测试组件
|
||||
├── PreferenceServiceTests.tsx # 服务层测试
|
||||
├── PreferenceBasicTests.tsx # 基础Hook测试
|
||||
├── PreferenceHookTests.tsx # 高级Hook测试
|
||||
├── PreferenceMultipleTests.tsx # 批量操作测试
|
||||
|
||||
# CacheService 测试组件
|
||||
├── CacheServiceTests.tsx # 直接API测试
|
||||
├── CacheBasicTests.tsx # Hook基础测试
|
||||
├── CacheAdvancedTests.tsx # 高级功能测试
|
||||
├── CacheStressTests.tsx # 压力测试
|
||||
|
||||
# DataApiService 测试组件
|
||||
├── DataApiBasicTests.tsx # 基础CRUD测试
|
||||
├── DataApiAdvancedTests.tsx # 高级功能测试
|
||||
├── DataApiHookTests.tsx # React Hooks测试
|
||||
└── DataApiStressTests.tsx # 压力测试
|
||||
```
|
||||
|
||||
## 跨窗口同步测试
|
||||
|
||||
🔄 **测试场景**:
|
||||
|
||||
### PreferenceService 跨窗口同步
|
||||
1. **实时同步验证**:在窗口#1中修改某个偏好设置,立即观察窗口#2是否同步更新
|
||||
2. **并发修改测试**:在两个窗口中快速连续修改同一设置,验证数据一致性
|
||||
3. **批量操作同步**:在一个窗口中批量更新多个设置,观察另一个窗口的同步表现
|
||||
4. **Hook实例同步**:验证多个usePreference hook实例是否正确同步
|
||||
|
||||
### CacheService 跨窗口同步
|
||||
1. **Shared Cache同步**:在窗口#1中设置共享缓存,观察窗口#2的实时更新
|
||||
2. **Persist Cache同步**:修改持久化缓存,验证所有窗口的localStorage同步
|
||||
3. **TTL跨窗口验证**:在一个窗口设置TTL,观察其他窗口的过期行为
|
||||
4. **并发缓存操作**:多窗口同时操作同一缓存key,验证数据一致性
|
||||
5. **Hook引用保护**:在一个窗口尝试删除其他窗口正在使用的缓存key
|
||||
|
||||
📋 **测试步骤**:
|
||||
|
||||
### PreferenceService 测试步骤
|
||||
1. 同时打开两个测试窗口(自动启动)
|
||||
2. 选择相同的偏好设置键进行测试
|
||||
3. 在窗口#1中修改值,观察窗口#2的反应
|
||||
4. 检查"Hook 高级功能测试"中的订阅触发次数是否增加
|
||||
5. 验证缓存状态和实时数据的一致性
|
||||
|
||||
### CacheService 测试步骤
|
||||
1. 同时打开两个测试窗口(自动启动)
|
||||
2. **Memory Cache测试**:仅在当前窗口有效,其他窗口不受影响
|
||||
3. **Shared Cache测试**:在窗口#1设置共享缓存,立即检查窗口#2是否同步
|
||||
4. **Persist Cache测试**:修改持久化缓存,验证localStorage和跨窗口同步
|
||||
5. **TTL测试**:设置带过期时间的缓存,观察倒计时和跨窗口过期行为
|
||||
6. **压力测试**:运行高频操作,监控性能指标和错误率
|
||||
7. **引用保护测试**:在Hook活跃时尝试删除key,验证保护机制
|
||||
|
||||
## 注意事项
|
||||
|
||||
⚠️ **重要警告**:
|
||||
|
||||
### PreferenceService 警告
|
||||
- **真实数据库存储**:测试使用真实的偏好设置系统
|
||||
- **跨应用同步**:修改的值会同步到主应用和所有测试窗口
|
||||
- **持久化影响**:所有更改都会持久化到SQLite数据库
|
||||
|
||||
### CacheService 警告
|
||||
- **内存占用**:压力测试可能消耗大量内存,影响浏览器性能
|
||||
- **localStorage影响**:大数据测试会占用浏览器存储空间(最大5-10MB)
|
||||
- **性能影响**:高频操作测试可能短暂影响UI响应性
|
||||
- **跨窗口影响**:Shared和Persist缓存会影响所有打开的窗口
|
||||
- **TTL清理**:过期缓存会自动清理,可能影响其他功能的测试数据
|
||||
|
||||
## 开发模式特性
|
||||
|
||||
- 自动打开DevTools便于调试
|
||||
- 支持热重载
|
||||
- 完整的TypeScript类型检查
|
||||
- React DevTools支持
|
||||
|
||||
## 💡 快速开始
|
||||
|
||||
### PreferenceService 快速测试
|
||||
1. **启动应用** - 自动打开2个测试窗口
|
||||
2. **选择测试** - 在"usePreference Hook 测试"中选择要测试的偏好设置键
|
||||
3. **🎛️ Slider联动测试** - 选择数值类型偏好设置,拖动Slider观察实时变化
|
||||
4. **跨窗口验证** - 在窗口#1中修改值,观察窗口#2是否同步
|
||||
5. **批量Slider测试** - 切换到"数值设置场景",同时拖动多个滑块测试批量同步
|
||||
6. **高级测试** - 使用"Hook 高级功能测试"验证订阅和缓存机制
|
||||
|
||||
### CacheService 快速测试
|
||||
1. **基础操作** - 使用"CacheService 直接API测试"进行get/set/delete操作
|
||||
2. **Hook测试** - 在"Cache Hooks 基础测试"中测试不同数据类型和默认值
|
||||
3. **TTL验证** - 设置2秒TTL缓存,观察实时倒计时和自动过期
|
||||
4. **跨窗口同步** - 设置Shared Cache,在另一窗口验证实时同步
|
||||
5. **持久化测试** - 修改Persist Cache,刷新页面验证localStorage持久化
|
||||
6. **压力测试** - 运行"快速操作测试",观察高频操作的性能表现
|
||||
7. **引用保护** - 启用Hook后尝试删除key,验证保护机制
|
||||
|
||||
## 🔧 技术实现
|
||||
|
||||
### 基础架构
|
||||
- **窗口管理**: DataRefactorMigrateService 单例管理多个测试窗口
|
||||
- **跨窗口识别**: 多源窗口编号支持,确保每个窗口都有唯一标识
|
||||
- **UI框架**: Ant Design + styled-components + React 18
|
||||
- **类型安全**: 完整的 TypeScript 类型检查和类型约束
|
||||
|
||||
### PreferenceService 技术实现
|
||||
- **数据同步**: 基于真实的 PreferenceService 和 IPC 通信
|
||||
- **实时主题**: 使用 useSyncExternalStore 实现主题、缩放等设置的实时UI响应
|
||||
- **类型约束**: 偏好设置键的完整TypeScript类型检查
|
||||
|
||||
### CacheService 技术实现
|
||||
- **三层缓存**: Memory (Map) + Shared (Map + IPC) + Persist (Map + localStorage)
|
||||
- **React集成**: useSyncExternalStore 实现外部状态订阅
|
||||
- **性能优化**: Object.is() 浅比较 + 深度相等性检查,跳过无效更新
|
||||
- **TTL管理**: 懒加载过期检查,基于时间戳的精确控制
|
||||
- **IPC同步**: 跨进程消息广播,支持批量操作和增量更新
|
||||
- **引用跟踪**: Set-based Hook引用计数,防止意外删除
|
||||
- **错误处理**: 完善的try-catch机制和用户友好的错误提示
|
||||
- **内存管理**: 自动清理、定时器管理和资源释放
|
||||
@ -1,448 +0,0 @@
|
||||
import { getToastUtilities } from '@renderer/components/TopView/toast'
|
||||
import { AppLogo } from '@renderer/config/env'
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Button, Card, Col, Divider, Layout, Row, Space, Tabs, Typography } from 'antd'
|
||||
import { Activity, AlertTriangle, Database, FlaskConical, Settings, TestTube, TrendingUp, Zap } from 'lucide-react'
|
||||
import React, { useEffect } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import CacheAdvancedTests from './components/CacheAdvancedTests'
|
||||
import CacheBasicTests from './components/CacheBasicTests'
|
||||
import CacheServiceTests from './components/CacheServiceTests'
|
||||
import CacheStressTests from './components/CacheStressTests'
|
||||
import DataApiAdvancedTests from './components/DataApiAdvancedTests'
|
||||
import DataApiBasicTests from './components/DataApiBasicTests'
|
||||
import DataApiHookTests from './components/DataApiHookTests'
|
||||
import DataApiStressTests from './components/DataApiStressTests'
|
||||
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 number from multiple sources
|
||||
const getWindowNumber = () => {
|
||||
// Try URL search params first
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const windowParam = urlParams.get('window')
|
||||
if (windowParam) {
|
||||
return windowParam
|
||||
}
|
||||
|
||||
// Try document title
|
||||
const windowTitle = document.title
|
||||
const windowMatch = windowTitle.match(/#(\d+)/)
|
||||
if (windowMatch) {
|
||||
return windowMatch[1]
|
||||
}
|
||||
|
||||
// Try window name
|
||||
if (window.name && window.name.includes('#')) {
|
||||
const nameMatch = window.name.match(/#(\d+)/)
|
||||
if (nameMatch) {
|
||||
return nameMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: generate based on window creation time
|
||||
return Math.floor(Date.now() / 1000) % 100
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.toast = getToastUtilities()
|
||||
}, [])
|
||||
|
||||
const windowNumber = getWindowNumber()
|
||||
|
||||
// Add theme preference monitoring for UI changes
|
||||
const [theme, setTheme] = usePreference('ui.theme_mode')
|
||||
const [language] = usePreference('app.language')
|
||||
const [zoomFactor] = usePreference('app.zoom_factor')
|
||||
|
||||
// Apply theme-based styling
|
||||
const isDarkTheme = theme === ThemeMode.dark
|
||||
const headerBg = isDarkTheme ? '#141414' : '#fff'
|
||||
const borderColor = isDarkTheme ? '#303030' : '#f0f0f0'
|
||||
const textColor = isDarkTheme ? '#fff' : '#000'
|
||||
|
||||
// Apply zoom factor
|
||||
const zoomValue = typeof zoomFactor === 'number' ? zoomFactor : 1.0
|
||||
|
||||
return (
|
||||
<Layout style={{ height: '100vh', transform: `scale(${zoomValue})`, transformOrigin: 'top left' }}>
|
||||
<Header
|
||||
style={{ background: headerBg, borderBottom: `1px solid ${borderColor}`, padding: '0 24px', color: textColor }}>
|
||||
<HeaderContent>
|
||||
<Space align="center">
|
||||
<img src={AppLogo} alt="Logo" style={{ width: 28, height: 28, borderRadius: 6 }} />
|
||||
<Title level={4} style={{ margin: 0, color: textColor }}>
|
||||
Test Window #{windowNumber} {isDarkTheme ? '🌙' : '☀️'}
|
||||
</Title>
|
||||
</Space>
|
||||
<Space>
|
||||
<FlaskConical size={20} color={isDarkTheme ? '#fff' : 'var(--color-primary)'} />
|
||||
<Text style={{ color: textColor }}>
|
||||
Cross-Window Sync Testing | {language || 'en-US'} | Zoom: {Math.round(zoomValue * 100)}%
|
||||
</Text>
|
||||
</Space>
|
||||
</HeaderContent>
|
||||
</Header>
|
||||
|
||||
<Content style={{ padding: '24px', overflow: 'auto', backgroundColor: isDarkTheme ? '#000' : '#f5f5f5' }}>
|
||||
<Container>
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* Introduction Card */}
|
||||
<Col span={24}>
|
||||
<Card style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Space align="center">
|
||||
<TestTube size={24} color="var(--color-primary)" />
|
||||
<Title level={3} style={{ margin: 0, color: textColor }}>
|
||||
数据重构项目测试套件 (窗口 #{windowNumber})
|
||||
</Title>
|
||||
</Space>
|
||||
<Text style={{ color: isDarkTheme ? '#d9d9d9' : 'rgba(0, 0, 0, 0.45)' }}>
|
||||
此测试窗口用于验证数据重构项目的各项功能,包括 PreferenceService、CacheService、DataApiService
|
||||
和相关 React hooks 的完整测试套件。
|
||||
</Text>
|
||||
<Text style={{ color: isDarkTheme ? '#d9d9d9' : 'rgba(0, 0, 0, 0.45)' }}>
|
||||
PreferenceService 测试使用真实的偏好设置系统,CacheService 测试使用三层缓存架构,DataApiService
|
||||
测试使用专用的测试路由和假数据。
|
||||
</Text>
|
||||
<Text style={{ color: 'var(--color-primary)', fontWeight: 'bold' }}>
|
||||
📋 跨窗口测试指南:在一个窗口中修改偏好设置,观察其他窗口是否实时同步更新。
|
||||
</Text>
|
||||
<Text style={{ color: 'var(--color-secondary)', fontWeight: 'bold' }}>
|
||||
🗄️ 缓存系统测试:三层缓存架构(Memory/Shared/Persist),支持跨窗口同步、TTL过期、性能优化。
|
||||
</Text>
|
||||
<Text style={{ color: 'var(--color-tertiary)', fontWeight: 'bold' }}>
|
||||
🚀 数据API测试:包含基础CRUD、高级功能、React hooks和压力测试,全面验证数据请求架构。
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Main Content Tabs */}
|
||||
<Col span={24}>
|
||||
<StyledTabs
|
||||
defaultActiveKey="preference"
|
||||
size="large"
|
||||
$isDark={isDarkTheme}
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderRadius: 8,
|
||||
padding: '0 16px',
|
||||
border: `1px solid ${borderColor}`
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: 'preference',
|
||||
label: (
|
||||
<Space>
|
||||
<Settings size={16} />
|
||||
<span>PreferenceService 测试</span>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* PreferenceService Basic Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Database size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>PreferenceService 基础测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<PreferenceServiceTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Basic Hook Tests */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Settings size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>usePreference Hook 测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<PreferenceBasicTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Hook Tests */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Settings size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>Hook 高级功能测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<PreferenceHookTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Multiple Preferences Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Database size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>usePreferences 批量操作测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<PreferenceMultipleTests />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'cache',
|
||||
label: (
|
||||
<Space>
|
||||
<Database size={16} />
|
||||
<span>CacheService 测试</span>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* Cache Service Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Database size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>CacheService 直接API测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<CacheServiceTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Cache Basic Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Settings size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>Cache Hooks 基础测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<CacheBasicTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Cache Advanced Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Activity size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>Cache 高级功能测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<CacheAdvancedTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Cache Stress Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<AlertTriangle size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>Cache 压力测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<CacheStressTests />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'dataapi',
|
||||
label: (
|
||||
<Space>
|
||||
<Zap size={16} />
|
||||
<span>DataApiService 测试</span>
|
||||
</Space>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[24, 24]}>
|
||||
{/* DataApi Basic Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Database size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>DataApi 基础功能测试 (CRUD操作)</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<DataApiBasicTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* DataApi Advanced Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Activity size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>DataApi 高级功能测试 (取消、重试、批量)</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<DataApiAdvancedTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* DataApi Hook Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<TrendingUp size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>DataApi React Hooks 测试</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<DataApiHookTests />
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* DataApi Stress Tests */}
|
||||
<Col span={24}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<AlertTriangle size={18} color={isDarkTheme ? '#fff' : '#000'} />
|
||||
<span style={{ color: textColor }}>DataApi 压力测试 (性能与错误处理)</span>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{ backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff', borderColor: borderColor }}>
|
||||
<DataApiStressTests />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Row justify="center">
|
||||
<Space>
|
||||
<Button
|
||||
icon={isDarkTheme ? '☀️' : '🌙'}
|
||||
onClick={async () => {
|
||||
await setTheme(isDarkTheme ? ThemeMode.light : ThemeMode.dark)
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#434343' : '#f0f0f0',
|
||||
borderColor: borderColor,
|
||||
color: textColor
|
||||
}}>
|
||||
{isDarkTheme ? '切换到亮色主题' : '切换到暗色主题'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
logger.info('Closing test window')
|
||||
window.close()
|
||||
}}>
|
||||
关闭测试窗口
|
||||
</Button>
|
||||
</Space>
|
||||
</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;
|
||||
`
|
||||
|
||||
const StyledTabs = styled(Tabs)<{ $isDark: boolean }>`
|
||||
.ant-tabs-nav {
|
||||
background: ${(props) => (props.$isDark ? '#262626' : '#fafafa')};
|
||||
border-radius: 6px 6px 0 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ant-tabs-tab {
|
||||
color: ${(props) => (props.$isDark ? '#d9d9d9' : '#666')} !important;
|
||||
|
||||
&:hover {
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')} !important;
|
||||
}
|
||||
|
||||
&.ant-tabs-tab-active {
|
||||
color: ${(props) => (props.$isDark ? '#1890ff' : '#1890ff')} !important;
|
||||
|
||||
.ant-tabs-tab-btn {
|
||||
color: ${(props) => (props.$isDark ? '#1890ff' : '#1890ff')} !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-tabs-ink-bar {
|
||||
background: ${(props) => (props.$isDark ? '#1890ff' : '#1890ff')};
|
||||
}
|
||||
|
||||
.ant-tabs-content {
|
||||
background: ${(props) => (props.$isDark ? '#1f1f1f' : '#fff')};
|
||||
border-radius: 0 0 6px 6px;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')};
|
||||
}
|
||||
`
|
||||
|
||||
export default TestApp
|
||||
@ -1,482 +0,0 @@
|
||||
import { cacheService } from '@renderer/data/CacheService'
|
||||
import { useCache, useSharedCache } from '@renderer/data/hooks/useCache'
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Badge, Button, Card, Col, Divider, message, Progress, Row, Space, Tag, Typography } from 'antd'
|
||||
import { Activity, AlertTriangle, CheckCircle, Clock, Shield, Timer, XCircle, Zap } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const logger = loggerService.withContext('CacheAdvancedTests')
|
||||
|
||||
/**
|
||||
* Advanced cache testing component
|
||||
* Tests TTL expiration, hook reference tracking, deep equality, performance
|
||||
*/
|
||||
const CacheAdvancedTests: React.FC = () => {
|
||||
const [currentTheme] = usePreference('ui.theme_mode')
|
||||
const isDarkTheme = currentTheme === ThemeMode.dark
|
||||
|
||||
// TTL Testing
|
||||
const [ttlKey] = useState('test-ttl-cache' as const)
|
||||
const [ttlValue, setTtlValue] = useCache(ttlKey as any, 'test-ttl-cache')
|
||||
const [ttlExpireTime, setTtlExpireTime] = useState<number | null>(null)
|
||||
const [ttlProgress, setTtlProgress] = useState(0)
|
||||
|
||||
// Hook Reference Tracking
|
||||
const [protectedKey] = useState('test-protected-cache' as const)
|
||||
const [protectedValue] = useCache(protectedKey as any, 'protected-value')
|
||||
const [deleteAttemptResult, setDeleteAttemptResult] = useState<string>('')
|
||||
|
||||
// Deep Equality Testing
|
||||
const [deepEqualKey] = useState('test-deep-equal' as const)
|
||||
const [objectValue, setObjectValue] = useCache(deepEqualKey as any, { nested: { count: 0 }, tags: ['initial'] })
|
||||
const [updateSkipCount] = useState(0)
|
||||
|
||||
// Performance Testing
|
||||
const [perfKey] = useState('test-performance' as const)
|
||||
const [perfValue, setPerfValue] = useCache(perfKey as any, 0)
|
||||
const [rapidUpdateCount, setRapidUpdateCount] = useState(0)
|
||||
const [subscriptionTriggers, setSubscriptionTriggers] = useState(0)
|
||||
const renderCountRef = useRef(0)
|
||||
const [displayRenderCount, setDisplayRenderCount] = useState(0)
|
||||
|
||||
// Multi-hook testing
|
||||
const [multiKey] = useState('test-multi-hook' as const)
|
||||
const [value1] = useCache(multiKey as any, 'hook-1-default')
|
||||
const [value2] = useCache(multiKey as any, 'hook-2-default')
|
||||
const [value3] = useSharedCache(multiKey as any, 'hook-3-shared')
|
||||
|
||||
const intervalRef = useRef<NodeJS.Timeout>(null)
|
||||
const performanceTestRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
// Update render count without causing re-renders
|
||||
renderCountRef.current += 1
|
||||
|
||||
// Track subscription changes
|
||||
useEffect(() => {
|
||||
const unsubscribe = cacheService.subscribe(perfKey, () => {
|
||||
setSubscriptionTriggers((prev) => prev + 1)
|
||||
})
|
||||
return unsubscribe
|
||||
}, [perfKey])
|
||||
|
||||
// TTL Testing Functions
|
||||
const startTTLTest = useCallback(
|
||||
(ttlMs: number) => {
|
||||
const testValue = { message: 'TTL Test', timestamp: Date.now() }
|
||||
cacheService.set(ttlKey, testValue, ttlMs)
|
||||
setTtlValue(testValue.message)
|
||||
|
||||
const expireAt = Date.now() + ttlMs
|
||||
setTtlExpireTime(expireAt)
|
||||
|
||||
// Clear previous interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
// Update progress every 100ms
|
||||
intervalRef.current = setInterval(() => {
|
||||
const now = Date.now()
|
||||
const remaining = Math.max(0, expireAt - now)
|
||||
const progress = Math.max(0, 100 - (remaining / ttlMs) * 100)
|
||||
|
||||
setTtlProgress(progress)
|
||||
|
||||
if (remaining <= 0) {
|
||||
clearInterval(intervalRef.current!)
|
||||
setTtlExpireTime(null)
|
||||
message.info('TTL expired, checking value...')
|
||||
|
||||
// Check if value is actually expired
|
||||
setTimeout(() => {
|
||||
const currentValue = cacheService.get(ttlKey)
|
||||
if (currentValue === undefined) {
|
||||
message.success('TTL expiration working correctly!')
|
||||
} else {
|
||||
message.warning('TTL expiration may have failed')
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}, 100)
|
||||
|
||||
message.info(`TTL test started: ${ttlMs}ms`)
|
||||
logger.info('TTL test started', { key: ttlKey, ttl: ttlMs, expireAt })
|
||||
},
|
||||
[ttlKey, setTtlValue]
|
||||
)
|
||||
|
||||
// Hook Reference Tracking Test
|
||||
const testDeleteProtection = () => {
|
||||
try {
|
||||
const deleted = cacheService.delete(protectedKey)
|
||||
setDeleteAttemptResult(deleted ? 'Deleted (unexpected!)' : 'Protected (expected)')
|
||||
logger.info('Delete protection test', { key: protectedKey, deleted })
|
||||
} catch (error) {
|
||||
setDeleteAttemptResult(`Error: ${(error as Error).message}`)
|
||||
logger.error('Delete protection test error', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// Deep Equality Testing
|
||||
const testDeepEquality = (operation: string) => {
|
||||
const currentCount = updateSkipCount
|
||||
|
||||
switch (operation) {
|
||||
case 'same-reference':
|
||||
// Set same reference - should skip
|
||||
setObjectValue(objectValue as { nested: { count: number }; tags: string[] })
|
||||
break
|
||||
|
||||
case 'same-content':
|
||||
// Set same content but different reference - should skip with deep comparison
|
||||
setObjectValue({ nested: { count: objectValue?.nested?.count || 0 }, tags: [...(objectValue?.tags || [])] })
|
||||
break
|
||||
|
||||
case 'different-content':
|
||||
// Set different content - should update
|
||||
setObjectValue({
|
||||
nested: { count: (objectValue?.nested?.count || 0) + 1 },
|
||||
tags: [...(objectValue?.tags || []), `update-${Date.now()}`]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
// Check if update count changed
|
||||
setTimeout(() => {
|
||||
if (currentCount === updateSkipCount) {
|
||||
message.success('Update skipped due to equality check')
|
||||
} else {
|
||||
message.info('Update applied due to content change')
|
||||
}
|
||||
}, 100)
|
||||
|
||||
logger.info('Deep equality test', { operation, currentCount, objectValue })
|
||||
}
|
||||
|
||||
// Performance Testing
|
||||
const startRapidUpdates = () => {
|
||||
let count = 0
|
||||
const startTime = Date.now()
|
||||
|
||||
performanceTestRef.current = setInterval(() => {
|
||||
count++
|
||||
setPerfValue(count)
|
||||
setRapidUpdateCount(count)
|
||||
|
||||
if (count >= 100) {
|
||||
clearInterval(performanceTestRef.current!)
|
||||
const duration = Date.now() - startTime
|
||||
message.success(`Rapid updates test completed: ${count} updates in ${duration}ms`)
|
||||
logger.info('Rapid updates test completed', { count, duration })
|
||||
}
|
||||
}, 10) // Update every 10ms
|
||||
}
|
||||
|
||||
const stopRapidUpdates = () => {
|
||||
if (performanceTestRef.current) {
|
||||
clearInterval(performanceTestRef.current)
|
||||
message.info('Rapid updates test stopped')
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
if (performanceTestRef.current) {
|
||||
clearInterval(performanceTestRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TestContainer $isDark={isDarkTheme}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Space>
|
||||
<Text type="secondary">
|
||||
Advanced Features • Renders: {displayRenderCount || renderCountRef.current} • Subscriptions:{' '}
|
||||
{subscriptionTriggers}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
renderCountRef.current = 0
|
||||
setDisplayRenderCount(0)
|
||||
setSubscriptionTriggers(0)
|
||||
}}>
|
||||
Reset Stats
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* TTL Testing */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Timer size={16} />
|
||||
<Text>TTL Expiration Testing</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
Key: <code>{ttlKey}</code>
|
||||
</Text>
|
||||
|
||||
<Space wrap>
|
||||
<Button size="small" onClick={() => startTTLTest(2000)} icon={<Clock size={12} />}>
|
||||
2s TTL
|
||||
</Button>
|
||||
<Button size="small" onClick={() => startTTLTest(5000)} icon={<Clock size={12} />}>
|
||||
5s TTL
|
||||
</Button>
|
||||
<Button size="small" onClick={() => startTTLTest(10000)} icon={<Clock size={12} />}>
|
||||
10s TTL
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{ttlExpireTime && (
|
||||
<div>
|
||||
<Text>Expiration Progress:</Text>
|
||||
<Progress
|
||||
percent={Math.round(ttlProgress)}
|
||||
status={ttlProgress >= 100 ? 'success' : 'active'}
|
||||
strokeColor={isDarkTheme ? '#1890ff' : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Current Value:</Text>
|
||||
<pre>{ttlValue ? JSON.stringify(ttlValue, null, 2) : 'undefined'}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Hook Reference Tracking */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Shield size={16} />
|
||||
<Text>Hook Reference Protection</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
Key: <code>{protectedKey}</code>
|
||||
</Text>
|
||||
<Badge status="processing" text="This hook is actively using the cache key" />
|
||||
|
||||
<Button danger onClick={testDeleteProtection} icon={<AlertTriangle size={12} />}>
|
||||
Attempt to Delete Key
|
||||
</Button>
|
||||
|
||||
{deleteAttemptResult && (
|
||||
<Tag color={deleteAttemptResult.includes('Protected') ? 'green' : 'red'}>{deleteAttemptResult}</Tag>
|
||||
)}
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Current Value:</Text>
|
||||
<pre>{JSON.stringify(protectedValue, null, 2)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* Deep Equality Testing */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<CheckCircle size={16} />
|
||||
<Text>Deep Equality Optimization</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
Key: <code>{deepEqualKey}</code>
|
||||
</Text>
|
||||
<Text>
|
||||
Skip Count: <Badge count={updateSkipCount} />
|
||||
</Text>
|
||||
|
||||
<Space direction="vertical">
|
||||
<Button size="small" onClick={() => testDeepEquality('same-reference')} icon={<XCircle size={12} />}>
|
||||
Set Same Reference
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => testDeepEquality('same-content')}
|
||||
icon={<CheckCircle size={12} />}>
|
||||
Set Same Content
|
||||
</Button>
|
||||
<Button size="small" onClick={() => testDeepEquality('different-content')} icon={<Zap size={12} />}>
|
||||
Set Different Content
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Current Object:</Text>
|
||||
<pre>{JSON.stringify(objectValue, null, 2)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Performance Testing */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Activity size={16} />
|
||||
<Text>Performance Testing</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
Key: <code>{perfKey}</code>
|
||||
</Text>
|
||||
<Text>
|
||||
Updates: <Badge count={rapidUpdateCount} />
|
||||
</Text>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={startRapidUpdates} icon={<Zap size={12} />}>
|
||||
Start Rapid Updates
|
||||
</Button>
|
||||
<Button onClick={stopRapidUpdates} icon={<XCircle size={12} />}>
|
||||
Stop
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Performance Value:</Text>
|
||||
<pre>{JSON.stringify(perfValue, null, 2)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Multi-Hook Synchronization */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Activity size={16} />
|
||||
<Text>Multi-Hook Synchronization Test</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
Testing multiple hooks using the same key: <code>{multiKey}</code>
|
||||
</Text>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="useCache Hook #1">
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<pre>{JSON.stringify(value1, null, 2)}</pre>
|
||||
</ResultDisplay>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="useCache Hook #2">
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<pre>{JSON.stringify(value2, null, 2)}</pre>
|
||||
</ResultDisplay>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="useSharedCache Hook #3">
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<pre>{JSON.stringify(value3, null, 2)}</pre>
|
||||
</ResultDisplay>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Space>
|
||||
<Button onClick={() => cacheService.set(multiKey, `Updated at ${new Date().toLocaleTimeString()}`)}>
|
||||
Update via CacheService
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => cacheService.setShared(multiKey, `Shared update at ${new Date().toLocaleTimeString()}`)}>
|
||||
Update via Shared Cache
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
💡 高级功能测试: TTL过期机制、Hook引用保护、深度相等性优化、性能测试、多Hook同步验证
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div<{ $isDark: boolean }>`
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')};
|
||||
`
|
||||
|
||||
const ResultDisplay = styled.div<{ $isDark: boolean }>`
|
||||
background: ${(props) => (props.$isDark ? '#0d1117' : '#f6f8fa')};
|
||||
border: 1px solid ${(props) => (props.$isDark ? '#30363d' : '#d0d7de')};
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: ${(props) => (props.$isDark ? '#e6edf3' : '#1f2328')};
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
`
|
||||
|
||||
export default CacheAdvancedTests
|
||||
@ -1,432 +0,0 @@
|
||||
import { useCache, usePersistCache, useSharedCache } from '@renderer/data/hooks/useCache'
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import type { RendererPersistCacheKey } from '@shared/data/cache/cacheSchemas'
|
||||
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Button, Card, Col, Divider, Input, message, Row, Select, Slider, Space, Typography } from 'antd'
|
||||
import { Database, Edit, Eye, HardDrive, RefreshCw, Users, Zap } from 'lucide-react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
const { Option } = Select
|
||||
|
||||
const logger = loggerService.withContext('CacheBasicTests')
|
||||
|
||||
/**
|
||||
* Basic cache hooks testing component
|
||||
* Tests useCache, useSharedCache, and usePersistCache hooks
|
||||
*/
|
||||
const CacheBasicTests: React.FC = () => {
|
||||
const [currentTheme] = usePreference('ui.theme_mode')
|
||||
const isDarkTheme = currentTheme === ThemeMode.dark
|
||||
|
||||
// useCache testing
|
||||
const [memoryCacheKey, setMemoryCacheKey] = useState('test-hook-memory-1')
|
||||
const [memoryCacheDefault, setMemoryCacheDefault] = useState('default-memory-value')
|
||||
const [newMemoryValue, setNewMemoryValue] = useState('')
|
||||
const [memoryValue, setMemoryValue] = useCache(memoryCacheKey as any, memoryCacheDefault)
|
||||
|
||||
// useSharedCache testing
|
||||
const [sharedCacheKey, setSharedCacheKey] = useState('test-hook-shared-1')
|
||||
const [sharedCacheDefault, setSharedCacheDefault] = useState('default-shared-value')
|
||||
const [newSharedValue, setNewSharedValue] = useState('')
|
||||
const [sharedValue, setSharedValue] = useSharedCache(sharedCacheKey as any, sharedCacheDefault)
|
||||
|
||||
// usePersistCache testing
|
||||
const [persistCacheKey, setPersistCacheKey] = useState<RendererPersistCacheKey>('example-1')
|
||||
const [newPersistValue, setNewPersistValue] = useState('')
|
||||
const [persistValue, setPersistValue] = usePersistCache(persistCacheKey)
|
||||
|
||||
// Testing different data types
|
||||
const [numberKey] = useState('test-number-cache' as const)
|
||||
const [numberValue, setNumberValue] = useCache(numberKey as any, 42)
|
||||
|
||||
const [objectKey] = useState('test-object-cache' as const)
|
||||
const [objectValue, setObjectValue] = useCache(objectKey as any, { name: 'test', count: 0, active: true })
|
||||
|
||||
// Stats
|
||||
const renderCountRef = useRef(0)
|
||||
const [displayRenderCount, setDisplayRenderCount] = useState(0)
|
||||
const [updateCount, setUpdateCount] = useState(0)
|
||||
|
||||
// Available persist keys
|
||||
const persistKeys: RendererPersistCacheKey[] = ['example-1', 'example-2', 'example-3', 'example-4']
|
||||
|
||||
// Update render count without causing re-renders
|
||||
renderCountRef.current += 1
|
||||
|
||||
const parseValue = (value: string): any => {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === undefined) return 'undefined'
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'string') return `"${value}"`
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
// Memory cache operations
|
||||
const handleMemoryUpdate = () => {
|
||||
try {
|
||||
const parsed = parseValue(newMemoryValue)
|
||||
setMemoryValue(parsed)
|
||||
setNewMemoryValue('')
|
||||
setUpdateCount((prev) => prev + 1)
|
||||
message.success(`Memory cache updated: ${memoryCacheKey}`)
|
||||
logger.info('Memory cache updated via hook', { key: memoryCacheKey, value: parsed })
|
||||
} catch (error) {
|
||||
message.error(`Memory cache update failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared cache operations
|
||||
const handleSharedUpdate = () => {
|
||||
try {
|
||||
const parsed = parseValue(newSharedValue)
|
||||
setSharedValue(parsed)
|
||||
setNewSharedValue('')
|
||||
setUpdateCount((prev) => prev + 1)
|
||||
message.success(`Shared cache updated: ${sharedCacheKey} (broadcasted to other windows)`)
|
||||
logger.info('Shared cache updated via hook', { key: sharedCacheKey, value: parsed })
|
||||
} catch (error) {
|
||||
message.error(`Shared cache update failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist cache operations
|
||||
const handlePersistUpdate = () => {
|
||||
try {
|
||||
let parsed: any
|
||||
// Handle different types based on schema
|
||||
if (persistCacheKey === 'example-1') {
|
||||
parsed = newPersistValue // string
|
||||
} else if (persistCacheKey === 'example-2') {
|
||||
parsed = parseInt(newPersistValue) || 0 // number
|
||||
} else if (persistCacheKey === 'example-3') {
|
||||
parsed = newPersistValue === 'true' // boolean
|
||||
} else if (persistCacheKey === 'example-4') {
|
||||
parsed = parseValue(newPersistValue) // object
|
||||
}
|
||||
|
||||
setPersistValue(parsed as any)
|
||||
setNewPersistValue('')
|
||||
setUpdateCount((prev) => prev + 1)
|
||||
message.success(`Persist cache updated: ${persistCacheKey} (saved + broadcasted)`)
|
||||
logger.info('Persist cache updated via hook', { key: persistCacheKey, value: parsed })
|
||||
} catch (error) {
|
||||
message.error(`Persist cache update failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Test different data types
|
||||
const handleNumberUpdate = (newValue: number) => {
|
||||
setNumberValue(newValue)
|
||||
setUpdateCount((prev) => prev + 1)
|
||||
logger.info('Number cache updated', { value: newValue })
|
||||
}
|
||||
|
||||
const handleObjectUpdate = (field: string, value: any) => {
|
||||
const currentValue = objectValue || { name: 'test', count: 0, active: true }
|
||||
setObjectValue({ ...currentValue, [field]: value })
|
||||
setUpdateCount((prev) => prev + 1)
|
||||
logger.info('Object cache updated', { field, value })
|
||||
}
|
||||
|
||||
return (
|
||||
<TestContainer $isDark={isDarkTheme}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Space>
|
||||
<Text type="secondary">
|
||||
React Hook Tests • Renders: {displayRenderCount || renderCountRef.current} • Updates: {updateCount}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
renderCountRef.current = 0
|
||||
setDisplayRenderCount(0)
|
||||
setUpdateCount(0)
|
||||
}}>
|
||||
Reset Stats
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* useCache Testing */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Zap size={16} />
|
||||
<Text>useCache Hook</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Cache Key"
|
||||
value={memoryCacheKey}
|
||||
onChange={(e) => setMemoryCacheKey(e.target.value)}
|
||||
prefix={<Database size={14} />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Default Value"
|
||||
value={memoryCacheDefault}
|
||||
onChange={(e) => setMemoryCacheDefault(e.target.value)}
|
||||
prefix={<Eye size={14} />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="New Value"
|
||||
value={newMemoryValue}
|
||||
onChange={(e) => setNewMemoryValue(e.target.value)}
|
||||
onPressEnter={handleMemoryUpdate}
|
||||
prefix={<Edit size={14} />}
|
||||
/>
|
||||
|
||||
<Button type="primary" onClick={handleMemoryUpdate} disabled={!newMemoryValue} block>
|
||||
Update Memory Cache
|
||||
</Button>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Current Value:</Text>
|
||||
<pre>{formatValue(memoryValue)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* useSharedCache Testing */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Users size={16} />
|
||||
<Text>useSharedCache Hook</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Cache Key"
|
||||
value={sharedCacheKey}
|
||||
onChange={(e) => setSharedCacheKey(e.target.value)}
|
||||
prefix={<Database size={14} />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="Default Value"
|
||||
value={sharedCacheDefault}
|
||||
onChange={(e) => setSharedCacheDefault(e.target.value)}
|
||||
prefix={<Eye size={14} />}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="New Value"
|
||||
value={newSharedValue}
|
||||
onChange={(e) => setNewSharedValue(e.target.value)}
|
||||
onPressEnter={handleSharedUpdate}
|
||||
prefix={<Edit size={14} />}
|
||||
/>
|
||||
|
||||
<Button type="primary" onClick={handleSharedUpdate} disabled={!newSharedValue} block>
|
||||
Update Shared Cache
|
||||
</Button>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Current Value:</Text>
|
||||
<pre>{formatValue(sharedValue)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* usePersistCache Testing */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HardDrive size={16} />
|
||||
<Text>usePersistCache Hook</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Select
|
||||
value={persistCacheKey}
|
||||
onChange={setPersistCacheKey}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Select persist key">
|
||||
{persistKeys.map((key) => (
|
||||
<Option key={key} value={key}>
|
||||
{key}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
placeholder="New Value"
|
||||
value={newPersistValue}
|
||||
onChange={(e) => setNewPersistValue(e.target.value)}
|
||||
onPressEnter={handlePersistUpdate}
|
||||
prefix={<Edit size={14} />}
|
||||
/>
|
||||
|
||||
<Button type="primary" onClick={handlePersistUpdate} disabled={!newPersistValue} block>
|
||||
Update Persist Cache
|
||||
</Button>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Current Value:</Text>
|
||||
<pre>{formatValue(persistValue)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Data Type Testing */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<RefreshCw size={16} />
|
||||
<Text>Number Type Testing</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
Key: <code>{numberKey}</code>
|
||||
</Text>
|
||||
<Text>
|
||||
Current Value: <strong>{numberValue}</strong>
|
||||
</Text>
|
||||
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
value={typeof numberValue === 'number' ? numberValue : 42}
|
||||
onChange={handleNumberUpdate}
|
||||
/>
|
||||
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleNumberUpdate(0)}>
|
||||
Reset to 0
|
||||
</Button>
|
||||
<Button size="small" onClick={() => handleNumberUpdate(Math.floor(Math.random() * 100))}>
|
||||
Random
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Database size={16} />
|
||||
<Text>Object Type Testing</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
Key: <code>{objectKey}</code>
|
||||
</Text>
|
||||
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="Name"
|
||||
value={objectValue?.name || ''}
|
||||
onChange={(e) => handleObjectUpdate('name', e.target.value)}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Count"
|
||||
type="number"
|
||||
value={objectValue?.count || 0}
|
||||
onChange={(e) => handleObjectUpdate('count', parseInt(e.target.value) || 0)}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Button
|
||||
type={objectValue?.active ? 'primary' : 'default'}
|
||||
onClick={() => handleObjectUpdate('active', !objectValue?.active)}>
|
||||
{objectValue?.active ? 'Active' : 'Inactive'}
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<pre>{formatValue(objectValue)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
💡 提示: useCache 仅在当前窗口有效 • useSharedCache 跨窗口实时同步 • usePersistCache 类型安全的持久化存储
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div<{ $isDark: boolean }>`
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')};
|
||||
`
|
||||
|
||||
const ResultDisplay = styled.div<{ $isDark: boolean }>`
|
||||
background: ${(props) => (props.$isDark ? '#0d1117' : '#f6f8fa')};
|
||||
border: 1px solid ${(props) => (props.$isDark ? '#30363d' : '#d0d7de')};
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: ${(props) => (props.$isDark ? '#e6edf3' : '#1f2328')};
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
`
|
||||
|
||||
export default CacheBasicTests
|
||||
@ -1,439 +0,0 @@
|
||||
import { cacheService } from '@renderer/data/CacheService'
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import type { RendererPersistCacheKey, RendererPersistCacheSchema } from '@shared/data/cache/cacheSchemas'
|
||||
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Button, Card, Col, Divider, Input, message, Row, Select, Space, Typography } from 'antd'
|
||||
import { Clock, Database, Edit, Eye, Trash2, Zap } from 'lucide-react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
const { Option } = Select
|
||||
const { TextArea } = Input
|
||||
|
||||
const logger = loggerService.withContext('CacheServiceTests')
|
||||
|
||||
/**
|
||||
* Direct CacheService API testing component
|
||||
* Tests memory, shared, and persist cache operations
|
||||
*/
|
||||
const CacheServiceTests: React.FC = () => {
|
||||
const [currentTheme] = usePreference('ui.theme_mode')
|
||||
const isDarkTheme = currentTheme === ThemeMode.dark
|
||||
|
||||
// State for test operations
|
||||
const [memoryKey, setMemoryKey] = useState('test-memory-1')
|
||||
const [memoryValue, setMemoryValue] = useState('{"type": "memory", "data": "test"}')
|
||||
const [memoryTTL, setMemoryTTL] = useState<string>('5000')
|
||||
|
||||
const [sharedKey, setSharedKey] = useState('test-shared-1')
|
||||
const [sharedValue, setSharedValue] = useState('{"type": "shared", "data": "cross-window"}')
|
||||
const [sharedTTL, setSharedTTL] = useState<string>('10000')
|
||||
|
||||
const [persistKey, setPersistKey] = useState<RendererPersistCacheKey>('example-1')
|
||||
const [persistValue, setPersistValue] = useState('updated-example-value')
|
||||
|
||||
// Display states
|
||||
const [memoryResult, setMemoryResult] = useState<any>(null)
|
||||
const [sharedResult, setSharedResult] = useState<any>(null)
|
||||
const [persistResult, setPersistResult] = useState<any>(null)
|
||||
|
||||
const [updateCount, setUpdateCount] = useState(0)
|
||||
|
||||
// Available persist keys from schema
|
||||
const persistKeys: RendererPersistCacheKey[] = ['example-1', 'example-2', 'example-3', 'example-4']
|
||||
|
||||
const parseValue = (value: string): any => {
|
||||
if (!value) return undefined
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return value // Return as string if not valid JSON
|
||||
}
|
||||
}
|
||||
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === undefined) return 'undefined'
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'string') return `"${value}"`
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
// Memory Cache Operations
|
||||
const handleMemorySet = () => {
|
||||
try {
|
||||
const parsed = parseValue(memoryValue)
|
||||
const ttl = memoryTTL ? parseInt(memoryTTL) : undefined
|
||||
cacheService.set(memoryKey, parsed, ttl)
|
||||
message.success(`Memory cache set: ${memoryKey}`)
|
||||
setUpdateCount((prev) => prev + 1)
|
||||
logger.info('Memory cache set', { key: memoryKey, value: parsed, ttl })
|
||||
} catch (error) {
|
||||
message.error(`Memory cache set failed: ${(error as Error).message}`)
|
||||
logger.error('Memory cache set failed', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemoryGet = () => {
|
||||
try {
|
||||
const result = cacheService.get(memoryKey)
|
||||
setMemoryResult(result)
|
||||
message.info(`Memory cache get: ${memoryKey}`)
|
||||
logger.info('Memory cache get', { key: memoryKey, result })
|
||||
} catch (error) {
|
||||
message.error(`Memory cache get failed: ${(error as Error).message}`)
|
||||
logger.error('Memory cache get failed', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemoryHas = () => {
|
||||
try {
|
||||
const exists = cacheService.has(memoryKey)
|
||||
message.info(`Memory cache has ${memoryKey}: ${exists}`)
|
||||
logger.info('Memory cache has', { key: memoryKey, exists })
|
||||
} catch (error) {
|
||||
message.error(`Memory cache has failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMemoryDelete = () => {
|
||||
try {
|
||||
const deleted = cacheService.delete(memoryKey)
|
||||
message.info(`Memory cache delete ${memoryKey}: ${deleted}`)
|
||||
setMemoryResult(undefined)
|
||||
logger.info('Memory cache delete', { key: memoryKey, deleted })
|
||||
} catch (error) {
|
||||
message.error(`Memory cache delete failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Shared Cache Operations
|
||||
const handleSharedSet = () => {
|
||||
try {
|
||||
const parsed = parseValue(sharedValue)
|
||||
const ttl = sharedTTL ? parseInt(sharedTTL) : undefined
|
||||
cacheService.setShared(sharedKey, parsed, ttl)
|
||||
message.success(`Shared cache set: ${sharedKey} (broadcasted to other windows)`)
|
||||
setUpdateCount((prev) => prev + 1)
|
||||
logger.info('Shared cache set', { key: sharedKey, value: parsed, ttl })
|
||||
} catch (error) {
|
||||
message.error(`Shared cache set failed: ${(error as Error).message}`)
|
||||
logger.error('Shared cache set failed', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSharedGet = () => {
|
||||
try {
|
||||
const result = cacheService.getShared(sharedKey)
|
||||
setSharedResult(result)
|
||||
message.info(`Shared cache get: ${sharedKey}`)
|
||||
logger.info('Shared cache get', { key: sharedKey, result })
|
||||
} catch (error) {
|
||||
message.error(`Shared cache get failed: ${(error as Error).message}`)
|
||||
logger.error('Shared cache get failed', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSharedHas = () => {
|
||||
try {
|
||||
const exists = cacheService.hasShared(sharedKey)
|
||||
message.info(`Shared cache has ${sharedKey}: ${exists}`)
|
||||
logger.info('Shared cache has', { key: sharedKey, exists })
|
||||
} catch (error) {
|
||||
message.error(`Shared cache has failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSharedDelete = () => {
|
||||
try {
|
||||
const deleted = cacheService.deleteShared(sharedKey)
|
||||
message.info(`Shared cache delete ${sharedKey}: ${deleted} (broadcasted to other windows)`)
|
||||
setSharedResult(undefined)
|
||||
logger.info('Shared cache delete', { key: sharedKey, deleted })
|
||||
} catch (error) {
|
||||
message.error(`Shared cache delete failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Persist Cache Operations
|
||||
const handlePersistSet = () => {
|
||||
try {
|
||||
let parsed: any
|
||||
// Handle different types based on the schema
|
||||
if (persistKey === 'example-1') {
|
||||
parsed = persistValue // string
|
||||
} else if (persistKey === 'example-2') {
|
||||
parsed = parseInt(persistValue) || 0 // number
|
||||
} else if (persistKey === 'example-3') {
|
||||
parsed = persistValue === 'true' // boolean
|
||||
} else if (persistKey === 'example-4') {
|
||||
parsed = parseValue(persistValue) // object
|
||||
}
|
||||
|
||||
cacheService.setPersist(persistKey, parsed as RendererPersistCacheSchema[typeof persistKey])
|
||||
message.success(`Persist cache set: ${persistKey} (saved to localStorage + broadcasted)`)
|
||||
setUpdateCount((prev) => prev + 1)
|
||||
logger.info('Persist cache set', { key: persistKey, value: parsed })
|
||||
} catch (error) {
|
||||
message.error(`Persist cache set failed: ${(error as Error).message}`)
|
||||
logger.error('Persist cache set failed', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePersistGet = () => {
|
||||
try {
|
||||
const result = cacheService.getPersist(persistKey)
|
||||
setPersistResult(result)
|
||||
message.info(`Persist cache get: ${persistKey}`)
|
||||
logger.info('Persist cache get', { key: persistKey, result })
|
||||
} catch (error) {
|
||||
message.error(`Persist cache get failed: ${(error as Error).message}`)
|
||||
logger.error('Persist cache get failed', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePersistHas = () => {
|
||||
try {
|
||||
const exists = cacheService.hasPersist(persistKey)
|
||||
message.info(`Persist cache has ${persistKey}: ${exists}`)
|
||||
logger.info('Persist cache has', { key: persistKey, exists })
|
||||
} catch (error) {
|
||||
message.error(`Persist cache has failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh results
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// Auto-get current values for display
|
||||
try {
|
||||
const memResult = cacheService.get(memoryKey)
|
||||
const sharedResult = cacheService.getShared(sharedKey)
|
||||
const persistResult = cacheService.getPersist(persistKey)
|
||||
|
||||
setMemoryResult(memResult)
|
||||
setSharedResult(sharedResult)
|
||||
setPersistResult(persistResult)
|
||||
} catch (error) {
|
||||
logger.error('Auto-refresh failed', error as Error)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [memoryKey, sharedKey, persistKey])
|
||||
|
||||
return (
|
||||
<TestContainer $isDark={isDarkTheme}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary">直接测试 CacheService API • Updates: {updateCount} • Auto-refresh: 1s</Text>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* Memory Cache Section */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Zap size={16} />
|
||||
<Text>Memory Cache</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Cache Key"
|
||||
value={memoryKey}
|
||||
onChange={(e) => setMemoryKey(e.target.value)}
|
||||
prefix={<Database size={14} />}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
placeholder="Value (JSON or string)"
|
||||
value={memoryValue}
|
||||
onChange={(e) => setMemoryValue(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="TTL (ms, optional)"
|
||||
value={memoryTTL}
|
||||
onChange={(e) => setMemoryTTL(e.target.value)}
|
||||
prefix={<Clock size={14} />}
|
||||
/>
|
||||
|
||||
<Space size="small" wrap>
|
||||
<Button size="small" type="primary" onClick={handleMemorySet} icon={<Edit size={12} />}>
|
||||
Set
|
||||
</Button>
|
||||
<Button size="small" onClick={handleMemoryGet} icon={<Eye size={12} />}>
|
||||
Get
|
||||
</Button>
|
||||
<Button size="small" onClick={handleMemoryHas} icon={<Database size={12} />}>
|
||||
Has
|
||||
</Button>
|
||||
<Button size="small" danger onClick={handleMemoryDelete} icon={<Trash2 size={12} />}>
|
||||
Delete
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Result:</Text>
|
||||
<pre>{formatValue(memoryResult)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Shared Cache Section */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Database size={16} />
|
||||
<Text>Shared Cache</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Cache Key"
|
||||
value={sharedKey}
|
||||
onChange={(e) => setSharedKey(e.target.value)}
|
||||
prefix={<Database size={14} />}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
placeholder="Value (JSON or string)"
|
||||
value={sharedValue}
|
||||
onChange={(e) => setSharedValue(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Input
|
||||
placeholder="TTL (ms, optional)"
|
||||
value={sharedTTL}
|
||||
onChange={(e) => setSharedTTL(e.target.value)}
|
||||
prefix={<Clock size={14} />}
|
||||
/>
|
||||
|
||||
<Space size="small" wrap>
|
||||
<Button size="small" type="primary" onClick={handleSharedSet} icon={<Edit size={12} />}>
|
||||
Set
|
||||
</Button>
|
||||
<Button size="small" onClick={handleSharedGet} icon={<Eye size={12} />}>
|
||||
Get
|
||||
</Button>
|
||||
<Button size="small" onClick={handleSharedHas} icon={<Database size={12} />}>
|
||||
Has
|
||||
</Button>
|
||||
<Button size="small" danger onClick={handleSharedDelete} icon={<Trash2 size={12} />}>
|
||||
Delete
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Result:</Text>
|
||||
<pre>{formatValue(sharedResult)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Persist Cache Section */}
|
||||
<Col span={8}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Eye size={16} />
|
||||
<Text>Persist Cache</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Select
|
||||
value={persistKey}
|
||||
onChange={setPersistKey}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Select persist key">
|
||||
{persistKeys.map((key) => (
|
||||
<Option key={key} value={key}>
|
||||
{key}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<TextArea
|
||||
placeholder="Value (type depends on key)"
|
||||
value={persistValue}
|
||||
onChange={(e) => setPersistValue(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
<Space size="small" wrap>
|
||||
<Button size="small" type="primary" onClick={handlePersistSet} icon={<Edit size={12} />}>
|
||||
Set
|
||||
</Button>
|
||||
<Button size="small" onClick={handlePersistGet} icon={<Eye size={12} />}>
|
||||
Get
|
||||
</Button>
|
||||
<Button size="small" onClick={handlePersistHas} icon={<Database size={12} />}>
|
||||
Has
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Result:</Text>
|
||||
<pre>{formatValue(persistResult)}</pre>
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
💡 提示: Memory cache 仅在当前窗口有效 • Shared cache 跨窗口同步 • Persist cache 持久化到 localStorage
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div<{ $isDark: boolean }>`
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')};
|
||||
`
|
||||
|
||||
const ResultDisplay = styled.div<{ $isDark: boolean }>`
|
||||
background: ${(props) => (props.$isDark ? '#0d1117' : '#f6f8fa')};
|
||||
border: 1px solid ${(props) => (props.$isDark ? '#30363d' : '#d0d7de')};
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: ${(props) => (props.$isDark ? '#e6edf3' : '#1f2328')};
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
`
|
||||
|
||||
export default CacheServiceTests
|
||||
@ -1,505 +0,0 @@
|
||||
import { cacheService } from '@renderer/data/CacheService'
|
||||
import { useCache, useSharedCache } from '@renderer/data/hooks/useCache'
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Alert, Button, Card, Col, message, Progress, Row, Space, Statistic, Tag, Typography } from 'antd'
|
||||
import { AlertTriangle, Database, HardDrive, TrendingUp, Users, Zap } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const logger = loggerService.withContext('CacheStressTests')
|
||||
|
||||
/**
|
||||
* Cache stress testing component
|
||||
* Tests performance limits, memory usage, concurrent operations
|
||||
*/
|
||||
const CacheStressTests: React.FC = () => {
|
||||
const [currentTheme] = usePreference('ui.theme_mode')
|
||||
const isDarkTheme = currentTheme === ThemeMode.dark
|
||||
|
||||
// Test States
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [testProgress, setTestProgress] = useState(0)
|
||||
const [testResults, setTestResults] = useState<any>({})
|
||||
|
||||
// Performance Metrics
|
||||
const [operationsPerSecond, setOperationsPerSecond] = useState(0)
|
||||
const [totalOperations, setTotalOperations] = useState(0)
|
||||
const [memoryUsage, setMemoryUsage] = useState(0)
|
||||
const renderCountRef = useRef(0)
|
||||
const [displayRenderCount, setDisplayRenderCount] = useState(0)
|
||||
const [errorCount, setErrorCount] = useState(0)
|
||||
|
||||
// Concurrent Testing
|
||||
const [concurrentValue1, setConcurrentValue1] = useCache('concurrent-test-1', 0)
|
||||
const [concurrentValue2, setConcurrentValue2] = useCache('concurrent-test-2', 0)
|
||||
const [concurrentShared, setConcurrentShared] = useSharedCache('concurrent-shared', 0)
|
||||
|
||||
// Large Data Testing
|
||||
const [largeDataKey] = useState('large-data-test' as const)
|
||||
const [largeDataSize, setLargeDataSize] = useState(0)
|
||||
const [, setLargeDataValue] = useCache(largeDataKey as any, {})
|
||||
|
||||
// Timers and refs
|
||||
const testTimerRef = useRef<NodeJS.Timeout>(null)
|
||||
const metricsTimerRef = useRef<NodeJS.Timeout>(null)
|
||||
const concurrentTimerRef = useRef<NodeJS.Timeout>(null)
|
||||
|
||||
// Update render count without causing re-renders
|
||||
renderCountRef.current += 1
|
||||
|
||||
// Memory Usage Estimation
|
||||
const estimateMemoryUsage = useCallback(() => {
|
||||
try {
|
||||
// Rough estimation based on localStorage size and objects
|
||||
const persistSize = localStorage.getItem('cs_cache_persist')?.length || 0
|
||||
const estimatedSize = persistSize + totalOperations * 50 // Rough estimate
|
||||
setMemoryUsage(estimatedSize)
|
||||
} catch (error) {
|
||||
logger.error('Memory usage estimation failed', error as Error)
|
||||
}
|
||||
}, [totalOperations])
|
||||
|
||||
useEffect(() => {
|
||||
estimateMemoryUsage()
|
||||
}, [totalOperations, estimateMemoryUsage])
|
||||
|
||||
// Rapid Fire Test
|
||||
const runRapidFireTest = useCallback(async () => {
|
||||
setIsRunning(true)
|
||||
setTestProgress(0)
|
||||
setTotalOperations(0)
|
||||
setErrorCount(0)
|
||||
|
||||
const startTime = Date.now()
|
||||
const testDuration = 10000 // 10 seconds
|
||||
const targetOperations = 1000
|
||||
|
||||
let operationCount = 0
|
||||
let errors = 0
|
||||
let shouldContinue = true // Use local variable instead of state
|
||||
|
||||
const performOperation = () => {
|
||||
if (!shouldContinue) return // Check local flag
|
||||
|
||||
try {
|
||||
const key = `rapid-test-${operationCount % 100}`
|
||||
const value = { id: operationCount, timestamp: Date.now(), data: Math.random().toString(36) }
|
||||
|
||||
// Alternate between different cache types
|
||||
if (operationCount % 3 === 0) {
|
||||
cacheService.set(key, value)
|
||||
} else if (operationCount % 3 === 1) {
|
||||
cacheService.setShared(key, value)
|
||||
} else {
|
||||
cacheService.get(key)
|
||||
}
|
||||
|
||||
operationCount++
|
||||
setTotalOperations(operationCount)
|
||||
setTestProgress((operationCount / targetOperations) * 100)
|
||||
|
||||
// Calculate operations per second
|
||||
const elapsed = Date.now() - startTime
|
||||
setOperationsPerSecond(Math.round((operationCount / elapsed) * 1000))
|
||||
|
||||
if (operationCount >= targetOperations || elapsed >= testDuration) {
|
||||
shouldContinue = false
|
||||
setIsRunning(false)
|
||||
setTestResults({
|
||||
duration: elapsed,
|
||||
operations: operationCount,
|
||||
opsPerSecond: Math.round((operationCount / elapsed) * 1000),
|
||||
errors
|
||||
})
|
||||
message.success(`Rapid fire test completed: ${operationCount} operations in ${elapsed}ms`)
|
||||
logger.info('Rapid fire test completed', { operationCount, elapsed, errors })
|
||||
return
|
||||
}
|
||||
|
||||
// Schedule next operation
|
||||
setTimeout(performOperation, 1)
|
||||
} catch (error) {
|
||||
errors++
|
||||
setErrorCount(errors)
|
||||
logger.error('Rapid fire test operation failed', error as Error)
|
||||
if (shouldContinue) {
|
||||
setTimeout(performOperation, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start the test
|
||||
performOperation()
|
||||
|
||||
// Store a reference to the shouldContinue flag for stopping
|
||||
testTimerRef.current = setTimeout(() => {
|
||||
// This timer will be cleared if the test is stopped early
|
||||
shouldContinue = false
|
||||
setIsRunning(false)
|
||||
}, testDuration)
|
||||
}, [])
|
||||
|
||||
// Concurrent Updates Test
|
||||
const startConcurrentTest = () => {
|
||||
let count1 = 0
|
||||
let count2 = 0
|
||||
let sharedCount = 0
|
||||
|
||||
concurrentTimerRef.current = setInterval(() => {
|
||||
// Simulate concurrent updates from different sources
|
||||
setConcurrentValue1(++count1)
|
||||
setConcurrentValue2(++count2)
|
||||
setConcurrentShared(++sharedCount)
|
||||
|
||||
if (count1 >= 100) {
|
||||
clearInterval(concurrentTimerRef.current!)
|
||||
message.success('Concurrent updates test completed')
|
||||
}
|
||||
}, 50) // Update every 50ms
|
||||
|
||||
message.info('Concurrent updates test started')
|
||||
}
|
||||
|
||||
const stopConcurrentTest = () => {
|
||||
if (concurrentTimerRef.current) {
|
||||
clearInterval(concurrentTimerRef.current)
|
||||
message.info('Concurrent updates test stopped')
|
||||
}
|
||||
}
|
||||
|
||||
// Large Data Test
|
||||
const generateLargeData = (sizeKB: number) => {
|
||||
const targetSize = sizeKB * 1024
|
||||
const baseString = 'a'.repeat(1024) // 1KB string
|
||||
const chunks = Math.floor(targetSize / 1024)
|
||||
|
||||
const largeObject = {
|
||||
id: Date.now(),
|
||||
size: sizeKB,
|
||||
chunks: chunks,
|
||||
data: Array(chunks).fill(baseString),
|
||||
metadata: {
|
||||
created: new Date().toISOString(),
|
||||
type: 'stress-test',
|
||||
description: `Large data test object of ${sizeKB}KB`
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
setLargeDataValue(largeObject)
|
||||
setLargeDataSize(sizeKB)
|
||||
message.success(`Large data test: ${sizeKB}KB object stored`)
|
||||
logger.info('Large data test completed', { sizeKB, chunks })
|
||||
} catch (error) {
|
||||
message.error(`Large data test failed: ${(error as Error).message}`)
|
||||
logger.error('Large data test failed', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
// LocalStorage Limit Test
|
||||
const testLocalStorageLimit = async () => {
|
||||
try {
|
||||
let testSize = 1
|
||||
let maxSize = 0
|
||||
|
||||
while (testSize <= 10240) {
|
||||
// Test up to 10MB
|
||||
try {
|
||||
const testData = 'x'.repeat(testSize * 1024) // testSize KB
|
||||
localStorage.setItem('storage-limit-test', testData)
|
||||
localStorage.removeItem('storage-limit-test')
|
||||
maxSize = testSize
|
||||
testSize *= 2
|
||||
} catch (error) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
message.info(`LocalStorage limit test: ~${maxSize}KB available`)
|
||||
logger.info('LocalStorage limit test completed', { maxSize })
|
||||
} catch (error) {
|
||||
message.error(`LocalStorage limit test failed: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop all tests
|
||||
const stopAllTests = () => {
|
||||
setIsRunning(false)
|
||||
if (testTimerRef.current) {
|
||||
clearTimeout(testTimerRef.current)
|
||||
testTimerRef.current = null
|
||||
}
|
||||
if (concurrentTimerRef.current) {
|
||||
clearInterval(concurrentTimerRef.current)
|
||||
concurrentTimerRef.current = null
|
||||
}
|
||||
message.info('All tests stopped')
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
useEffect(() => {
|
||||
const metricsCleanup = metricsTimerRef.current
|
||||
return () => {
|
||||
if (testTimerRef.current) clearTimeout(testTimerRef.current)
|
||||
if (metricsCleanup) clearInterval(metricsCleanup)
|
||||
if (concurrentTimerRef.current) clearInterval(concurrentTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TestContainer $isDark={isDarkTheme}>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Space>
|
||||
<Text type="secondary">
|
||||
Stress Testing • Renders: {displayRenderCount || renderCountRef.current} • Errors: {errorCount}
|
||||
</Text>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
renderCountRef.current = 0
|
||||
setDisplayRenderCount(0)
|
||||
setErrorCount(0)
|
||||
}}>
|
||||
Reset Stats
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
<Row gutter={[16, 8]}>
|
||||
<Col span={6}>
|
||||
<Statistic title="Operations/Second" value={operationsPerSecond} prefix={<TrendingUp size={16} />} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="Total Operations" value={totalOperations} prefix={<Database size={16} />} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="Memory Usage (bytes)" value={memoryUsage} prefix={<HardDrive size={16} />} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Error Count"
|
||||
value={errorCount}
|
||||
valueStyle={{ color: errorCount > 0 ? '#ff4d4f' : undefined }}
|
||||
prefix={<AlertTriangle size={16} />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* Rapid Fire Test */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Zap size={16} />
|
||||
<Text>Rapid Fire Operations</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>High-frequency cache operations test (1000 ops in 10s)</Text>
|
||||
|
||||
<Progress
|
||||
percent={Math.round(testProgress)}
|
||||
status={isRunning ? 'active' : testProgress > 0 ? 'success' : 'normal'}
|
||||
strokeColor={isDarkTheme ? '#1890ff' : undefined}
|
||||
/>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={runRapidFireTest} disabled={isRunning} icon={<Zap size={12} />}>
|
||||
Start Rapid Fire Test
|
||||
</Button>
|
||||
<Button onClick={stopAllTests} disabled={!isRunning} danger>
|
||||
Stop All Tests
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{testResults.operations && (
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Test Results:</Text>
|
||||
<pre>{JSON.stringify(testResults, null, 2)}</pre>
|
||||
</ResultDisplay>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Concurrent Updates Test */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<Users size={16} />
|
||||
<Text>Concurrent Updates</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>Multiple hooks updating simultaneously</Text>
|
||||
|
||||
<Row gutter={8}>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Hook 1">
|
||||
<Statistic value={concurrentValue1} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Hook 2">
|
||||
<Statistic value={concurrentValue2} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Shared">
|
||||
<Statistic value={concurrentShared} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Space>
|
||||
<Button type="primary" onClick={startConcurrentTest} icon={<Users size={12} />}>
|
||||
Start Concurrent Test
|
||||
</Button>
|
||||
<Button onClick={stopConcurrentTest}>Stop</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* Large Data Test */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<HardDrive size={16} />
|
||||
<Text>Large Data Storage</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>Test cache with large objects</Text>
|
||||
|
||||
<Space wrap>
|
||||
<Button size="small" onClick={() => generateLargeData(10)}>
|
||||
10KB Object
|
||||
</Button>
|
||||
<Button size="small" onClick={() => generateLargeData(100)}>
|
||||
100KB Object
|
||||
</Button>
|
||||
<Button size="small" onClick={() => generateLargeData(1024)}>
|
||||
1MB Object
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{largeDataSize > 0 && (
|
||||
<Alert
|
||||
message={`Large data test completed`}
|
||||
description={`Successfully stored ${largeDataSize}KB object in cache`}
|
||||
type="success"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
|
||||
<ResultDisplay $isDark={isDarkTheme}>
|
||||
<Text strong>Large Data Key: </Text>
|
||||
<code>{largeDataKey}</code>
|
||||
<br />
|
||||
<Text strong>Current Size: </Text>
|
||||
{largeDataSize}KB
|
||||
</ResultDisplay>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Storage Limits Test */}
|
||||
<Col span={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<AlertTriangle size={16} />
|
||||
<Text>Storage Limits</Text>
|
||||
</Space>
|
||||
}
|
||||
size="small"
|
||||
style={{
|
||||
backgroundColor: isDarkTheme ? '#1f1f1f' : '#fff',
|
||||
borderColor: isDarkTheme ? '#303030' : '#d9d9d9'
|
||||
}}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>Test localStorage capacity and limits</Text>
|
||||
|
||||
<Alert
|
||||
message="Warning"
|
||||
description="This test may temporarily consume significant browser storage"
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Button onClick={testLocalStorageLimit} icon={<Database size={12} />}>
|
||||
Test Storage Limits
|
||||
</Button>
|
||||
|
||||
<Space direction="vertical">
|
||||
<Tag color="blue">Persist Cache Size Check</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Current persist cache: ~
|
||||
{Math.round(JSON.stringify(localStorage.getItem('cs_cache_persist')).length / 1024)}KB
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
⚠️ 压力测试: 高频操作、并发更新、大数据存储、存储限制测试 • 可能会影响浏览器性能
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div<{ $isDark: boolean }>`
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')};
|
||||
`
|
||||
|
||||
const ResultDisplay = styled.div<{ $isDark: boolean }>`
|
||||
background: ${(props) => (props.$isDark ? '#0d1117' : '#f6f8fa')};
|
||||
border: 1px solid ${(props) => (props.$isDark ? '#30363d' : '#d0d7de')};
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
font-size: 11px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: ${(props) => (props.$isDark ? '#e6edf3' : '#1f2328')};
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
}
|
||||
`
|
||||
|
||||
export default CacheStressTests
|
||||
@ -1,689 +0,0 @@
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { Alert, Button, Card, Col, message, Row, Space, Statistic, Table, Tag, Typography } from 'antd'
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
Shield,
|
||||
StopCircle,
|
||||
Timer,
|
||||
XCircle,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import React, { useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { dataApiService } from '../../../data/DataApiService'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const logger = loggerService.withContext('DataApiAdvancedTests')
|
||||
|
||||
interface AdvancedTestResult {
|
||||
id: string
|
||||
name: string
|
||||
category: 'cancellation' | 'retry' | 'batch' | 'error' | 'performance'
|
||||
status: 'pending' | 'running' | 'success' | 'error' | 'cancelled'
|
||||
startTime?: number
|
||||
duration?: number
|
||||
response?: any
|
||||
error?: string
|
||||
metadata?: Record<string, any>
|
||||
}
|
||||
|
||||
interface RetryTestConfig {
|
||||
maxRetries: number
|
||||
retryDelay: number
|
||||
backoffMultiplier: number
|
||||
}
|
||||
|
||||
const DataApiAdvancedTests: React.FC = () => {
|
||||
const [testResults, setTestResults] = useState<AdvancedTestResult[]>([])
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [, setCancelledRequests] = useState<string[]>([])
|
||||
const [, setRetryStats] = useState<any>(null)
|
||||
const [performanceStats, setPerformanceStats] = useState<any>(null)
|
||||
|
||||
// Keep track of active abort controllers
|
||||
const abortControllersRef = useRef<Map<string, AbortController>>(new Map())
|
||||
|
||||
const updateTestResult = (id: string, updates: Partial<AdvancedTestResult>) => {
|
||||
setTestResults((prev) => prev.map((result) => (result.id === id ? { ...result, ...updates } : result)))
|
||||
}
|
||||
|
||||
const addTestResult = (result: AdvancedTestResult) => {
|
||||
setTestResults((prev) => [...prev, result])
|
||||
}
|
||||
|
||||
const runAdvancedTest = async (
|
||||
testId: string,
|
||||
testName: string,
|
||||
category: AdvancedTestResult['category'],
|
||||
testFn: (signal?: AbortSignal) => Promise<any>
|
||||
) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const testResult: AdvancedTestResult = {
|
||||
id: testId,
|
||||
name: testName,
|
||||
category,
|
||||
status: 'running',
|
||||
startTime
|
||||
}
|
||||
|
||||
addTestResult(testResult)
|
||||
|
||||
// Create abort controller for cancellation testing
|
||||
const abortController = new AbortController()
|
||||
abortControllersRef.current.set(testId, abortController)
|
||||
|
||||
try {
|
||||
logger.info(`Starting advanced test: ${testName}`, { category })
|
||||
|
||||
const response = await testFn(abortController.signal)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.info(`Advanced test completed: ${testName}`, { duration, response })
|
||||
|
||||
updateTestResult(testId, {
|
||||
status: 'success',
|
||||
duration,
|
||||
response,
|
||||
error: undefined
|
||||
})
|
||||
|
||||
message.success(`${testName} completed successfully`)
|
||||
return response
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
const isAborted = error instanceof Error && error.message.includes('cancelled')
|
||||
const status = isAborted ? 'cancelled' : 'error'
|
||||
|
||||
logger.error(`Advanced test ${status}: ${testName}`, error as Error)
|
||||
|
||||
updateTestResult(testId, {
|
||||
status,
|
||||
duration,
|
||||
error: errorMessage,
|
||||
response: undefined
|
||||
})
|
||||
|
||||
if (isAborted) {
|
||||
message.warning(`${testName} was cancelled`)
|
||||
setCancelledRequests((prev) => [...prev, testId])
|
||||
} else {
|
||||
message.error(`${testName} failed: ${errorMessage}`)
|
||||
}
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
abortControllersRef.current.delete(testId)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelTest = (testId: string) => {
|
||||
const controller = abortControllersRef.current.get(testId)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
message.info(`Test ${testId} cancellation requested`)
|
||||
}
|
||||
}
|
||||
|
||||
const cancelAllTests = () => {
|
||||
const controllers = Array.from(abortControllersRef.current.entries())
|
||||
controllers.forEach(([, controller]) => {
|
||||
controller.abort()
|
||||
})
|
||||
message.info(`${controllers.length} tests cancelled`)
|
||||
}
|
||||
|
||||
// Request Cancellation Tests
|
||||
const testRequestCancellation = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
// Note: Request cancellation is not supported with direct IPC
|
||||
// These tests demonstrate that cancellation attempts have no effect
|
||||
|
||||
// Test 1: Attempt to "cancel" a slow request (will complete normally)
|
||||
await runAdvancedTest('cancel-slow', 'Slow Request (No Cancel Support)', 'cancellation', async () => {
|
||||
// Note: This will complete normally since cancellation is not supported
|
||||
return await dataApiService.post('/test/slow', { body: { delay: 1000 } })
|
||||
})
|
||||
|
||||
// Test 2: Quick request (normal completion)
|
||||
await runAdvancedTest('cancel-quick', 'Quick Request Test', 'cancellation', async () => {
|
||||
return await dataApiService.get('/test/items')
|
||||
})
|
||||
|
||||
// Test 3: Demonstrate that cancel methods exist but have no effect
|
||||
await runAdvancedTest('service-cancel', 'Cancel Method Test (No Effect)', 'cancellation', async () => {
|
||||
// These methods exist but log warnings and have no effect
|
||||
dataApiService.cancelRequest('dummy-id')
|
||||
dataApiService.cancelAllRequests()
|
||||
|
||||
// Return successful test data
|
||||
return { cancelled: false, message: 'Cancel methods called but have no effect with direct IPC' }
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Retry Mechanism Tests
|
||||
const testRetryMechanism = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
// Configure retry settings for testing
|
||||
const originalConfig = dataApiService.getRetryConfig()
|
||||
const testConfig: RetryTestConfig = {
|
||||
maxRetries: 3,
|
||||
retryDelay: 500,
|
||||
backoffMultiplier: 2
|
||||
}
|
||||
|
||||
dataApiService.configureRetry(testConfig)
|
||||
setRetryStats({ config: testConfig, attempts: [] })
|
||||
|
||||
// Test 1: Network error retry
|
||||
await runAdvancedTest('retry-network', 'Network Error Retry Test', 'retry', async () => {
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: 'network' } })
|
||||
} catch (error) {
|
||||
return {
|
||||
retriedAndFailed: true,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: 'Retry mechanism tested - expected to fail after retries'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test 2: Timeout retry
|
||||
await runAdvancedTest('retry-timeout', 'Timeout Error Retry Test', 'retry', async () => {
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: 'timeout' } })
|
||||
} catch (error) {
|
||||
return {
|
||||
retriedAndFailed: true,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: 'Timeout retry tested - expected to fail after retries'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test 3: Server error retry
|
||||
await runAdvancedTest('retry-server', 'Server Error Retry Test', 'retry', async () => {
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: 'server' } })
|
||||
} catch (error) {
|
||||
return {
|
||||
retriedAndFailed: true,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: 'Server error retry tested - expected to fail after retries'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Restore original retry configuration
|
||||
dataApiService.configureRetry(originalConfig)
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Batch Operations Test
|
||||
const testBatchOperations = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
// Test 1: Batch GET requests
|
||||
await runAdvancedTest('batch-get', 'Batch GET Requests', 'batch', async () => {
|
||||
const requests = [
|
||||
{ method: 'GET', path: '/test/items', params: { page: 1, limit: 3 } },
|
||||
{ method: 'GET', path: '/test/stats' },
|
||||
{ method: 'GET', path: '/test/items', params: { page: 2, limit: 3 } }
|
||||
]
|
||||
|
||||
return await dataApiService.batch(
|
||||
requests.map((req, index) => ({
|
||||
id: `batch-get-${index}`,
|
||||
method: req.method as any,
|
||||
path: req.path,
|
||||
params: req.params
|
||||
})),
|
||||
{ parallel: true }
|
||||
)
|
||||
})
|
||||
|
||||
// Test 2: Mixed batch operations
|
||||
await runAdvancedTest('batch-mixed', 'Mixed Batch Operations', 'batch', async () => {
|
||||
const requests = [
|
||||
{
|
||||
id: 'batch-create-item',
|
||||
method: 'POST',
|
||||
path: '/test/items',
|
||||
body: {
|
||||
title: `Batch Created Item ${Date.now()}`,
|
||||
description: 'Created via batch operation',
|
||||
type: 'batch-test'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'batch-get-items',
|
||||
method: 'GET',
|
||||
path: '/test/items',
|
||||
params: { page: 1, limit: 5 }
|
||||
}
|
||||
]
|
||||
|
||||
return await dataApiService.batch(requests as any, { parallel: false })
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Error Handling Tests
|
||||
const testErrorHandling = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
const errorTypes = ['notfound', 'validation', 'unauthorized', 'server', 'ratelimit']
|
||||
|
||||
try {
|
||||
for (const errorType of errorTypes) {
|
||||
await runAdvancedTest(`error-${errorType}`, `Error Type: ${errorType.toUpperCase()}`, 'error', async () => {
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: errorType as any } })
|
||||
} catch (error) {
|
||||
return {
|
||||
errorTested: true,
|
||||
errorType,
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Successfully caught and handled ${errorType} error`
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Performance Tests
|
||||
const testPerformance = async () => {
|
||||
if (isRunning) return
|
||||
setIsRunning(true)
|
||||
|
||||
try {
|
||||
const concurrentRequests = 10
|
||||
const stats = {
|
||||
concurrentRequests,
|
||||
totalTime: 0,
|
||||
averageTime: 0,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
requests: [] as any[]
|
||||
}
|
||||
|
||||
// Test concurrent requests
|
||||
await runAdvancedTest('perf-concurrent', `${concurrentRequests} Concurrent Requests`, 'performance', async () => {
|
||||
const startTime = Date.now()
|
||||
|
||||
const promises = Array.from({ length: concurrentRequests }, (_, i) =>
|
||||
dataApiService.get('/test/items').then(
|
||||
(result) => ({ success: true, result, index: i }),
|
||||
(error) => ({ success: false, error: error instanceof Error ? error.message : 'Unknown error', index: i })
|
||||
)
|
||||
)
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
const totalTime = Date.now() - startTime
|
||||
|
||||
stats.totalTime = totalTime
|
||||
stats.averageTime = totalTime / concurrentRequests
|
||||
stats.successCount = results.filter((r) => r.success).length
|
||||
stats.errorCount = results.filter((r) => !r.success).length
|
||||
stats.requests = results
|
||||
|
||||
return stats
|
||||
})
|
||||
|
||||
setPerformanceStats(stats)
|
||||
|
||||
// Test memory usage
|
||||
await runAdvancedTest('perf-memory', 'Memory Usage Test', 'performance', async () => {
|
||||
const initialMemory = (performance as any).memory?.usedJSHeapSize || 0
|
||||
|
||||
// Create many requests to test memory handling
|
||||
const largeRequests = Array.from({ length: 50 }, () => dataApiService.get('/test/items'))
|
||||
|
||||
await Promise.allSettled(largeRequests)
|
||||
|
||||
const finalMemory = (performance as any).memory?.usedJSHeapSize || 0
|
||||
const memoryIncrease = finalMemory - initialMemory
|
||||
|
||||
return {
|
||||
initialMemory,
|
||||
finalMemory,
|
||||
memoryIncrease,
|
||||
memoryIncreaseKB: Math.round(memoryIncrease / 1024),
|
||||
message: `Memory increase: ${Math.round(memoryIncrease / 1024)}KB after 50 requests`
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetTests = () => {
|
||||
// Cancel any running tests
|
||||
cancelAllTests()
|
||||
|
||||
setTestResults([])
|
||||
setCancelledRequests([])
|
||||
setRetryStats(null)
|
||||
setPerformanceStats(null)
|
||||
setIsRunning(false)
|
||||
message.info('Advanced tests reset')
|
||||
}
|
||||
|
||||
const getStatusColor = (status: AdvancedTestResult['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'cancelled':
|
||||
return 'warning'
|
||||
case 'running':
|
||||
return 'processing'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: AdvancedTestResult['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle size={16} />
|
||||
case 'error':
|
||||
return <XCircle size={16} />
|
||||
case 'cancelled':
|
||||
return <StopCircle size={16} />
|
||||
case 'running':
|
||||
return <Activity size={16} className="animate-spin" />
|
||||
default:
|
||||
return <Clock size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
const getCategoryIcon = (category: AdvancedTestResult['category']) => {
|
||||
switch (category) {
|
||||
case 'cancellation':
|
||||
return <StopCircle size={16} />
|
||||
case 'retry':
|
||||
return <RotateCcw size={16} />
|
||||
case 'batch':
|
||||
return <Zap size={16} />
|
||||
case 'error':
|
||||
return <AlertTriangle size={16} />
|
||||
case 'performance':
|
||||
return <Timer size={16} />
|
||||
default:
|
||||
return <Activity size={16} />
|
||||
}
|
||||
}
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
title: 'Category',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (category: string) => (
|
||||
<Space>
|
||||
{getCategoryIcon(category as any)}
|
||||
<Text>{category}</Text>
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Test',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string) => (
|
||||
<Text code style={{ fontSize: 12 }}>
|
||||
{name}
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: AdvancedTestResult['status']) => (
|
||||
<Tag color={getStatusColor(status)} icon={getStatusIcon(status)}>
|
||||
{status.toUpperCase()}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
render: (duration?: number) => (duration ? `${duration}ms` : '-')
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: any, record: AdvancedTestResult) => (
|
||||
<Space>
|
||||
{record.status === 'running' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<StopCircle size={12} />}
|
||||
onClick={() => cancelTest(record.id)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Control Panel */}
|
||||
<Card size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<StopCircle size={16} />}
|
||||
onClick={testRequestCancellation}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Cancellation
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<RotateCcw size={16} />}
|
||||
onClick={testRetryMechanism}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Retry
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Zap size={16} />}
|
||||
onClick={testBatchOperations}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Batch
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
onClick={testErrorHandling}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Errors
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16} style={{ marginTop: 16 }}>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Timer size={16} />}
|
||||
onClick={testPerformance}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Test Performance
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button icon={<Shield size={16} />} onClick={resetTests} disabled={isRunning} block>
|
||||
Reset Tests
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Space style={{ float: 'right' }}>
|
||||
{abortControllersRef.current.size > 0 && (
|
||||
<Button danger icon={<StopCircle size={16} />} onClick={cancelAllTests}>
|
||||
Cancel All ({abortControllersRef.current.size})
|
||||
</Button>
|
||||
)}
|
||||
<Text type="secondary">Running: {testResults.filter((t) => t.status === 'running').length}</Text>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Statistics */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="Total Tests" value={testResults.length} prefix={<Activity size={16} />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Successful"
|
||||
value={testResults.filter((t) => t.status === 'success').length}
|
||||
prefix={<CheckCircle size={16} />}
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Failed"
|
||||
value={testResults.filter((t) => t.status === 'error').length}
|
||||
prefix={<XCircle size={16} />}
|
||||
valueStyle={{ color: '#cf1322' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="Cancelled"
|
||||
value={testResults.filter((t) => t.status === 'cancelled').length}
|
||||
prefix={<StopCircle size={16} />}
|
||||
valueStyle={{ color: '#d48806' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Performance Stats */}
|
||||
{performanceStats && (
|
||||
<Card title="Performance Statistics" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic title="Concurrent Requests" value={performanceStats.concurrentRequests} />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="Total Time" value={performanceStats.totalTime} suffix="ms" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="Average Time" value={Math.round(performanceStats.averageTime)} suffix="ms" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Success Rate"
|
||||
value={Math.round((performanceStats.successCount / performanceStats.concurrentRequests) * 100)}
|
||||
suffix="%"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Test Results Table */}
|
||||
<Card title="Advanced Test Results" size="small">
|
||||
{testResults.length === 0 ? (
|
||||
<Alert
|
||||
message="No advanced tests executed yet"
|
||||
description="Click any of the test buttons above to start testing advanced features"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={testResults}
|
||||
columns={tableColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 15 }}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div`
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export default DataApiAdvancedTests
|
||||
@ -1,604 +0,0 @@
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { Alert, Button, Card, Col, Divider, Input, message, Row, Space, Table, Tag, Typography } from 'antd'
|
||||
import { Check, Database, Play, RotateCcw, X } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { dataApiService } from '../../../data/DataApiService'
|
||||
|
||||
const { Text, Paragraph } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
const logger = loggerService.withContext('DataApiBasicTests')
|
||||
|
||||
interface TestResult {
|
||||
id: string
|
||||
name: string
|
||||
status: 'pending' | 'running' | 'success' | 'error'
|
||||
duration?: number
|
||||
response?: any
|
||||
error?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
interface TestItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
type: string
|
||||
status: string
|
||||
priority: string
|
||||
tags: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
const DataApiBasicTests: React.FC = () => {
|
||||
const [testResults, setTestResults] = useState<TestResult[]>([])
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [testItems, setTestItems] = useState<TestItem[]>([])
|
||||
const [selectedItem, setSelectedItem] = useState<TestItem | null>(null)
|
||||
const [newItemTitle, setNewItemTitle] = useState('')
|
||||
const [newItemDescription, setNewItemDescription] = useState('')
|
||||
const [newItemType, setNewItemType] = useState('data')
|
||||
|
||||
const updateTestResult = (id: string, updates: Partial<TestResult>) => {
|
||||
setTestResults((prev) => prev.map((result) => (result.id === id ? { ...result, ...updates } : result)))
|
||||
}
|
||||
|
||||
const runTest = async (testId: string, testName: string, testFn: () => Promise<any>) => {
|
||||
const startTime = Date.now()
|
||||
|
||||
updateTestResult(testId, {
|
||||
status: 'running',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
try {
|
||||
logger.info(`Starting test: ${testName}`)
|
||||
const response = await testFn()
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.info(`Test completed: ${testName}`, { duration, response })
|
||||
|
||||
updateTestResult(testId, {
|
||||
status: 'success',
|
||||
duration,
|
||||
response,
|
||||
error: undefined
|
||||
})
|
||||
|
||||
message.success(`${testName} completed successfully`)
|
||||
return response
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
logger.error(`Test failed: ${testName}`, error as Error)
|
||||
|
||||
updateTestResult(testId, {
|
||||
status: 'error',
|
||||
duration,
|
||||
error: errorMessage,
|
||||
response: undefined
|
||||
})
|
||||
|
||||
message.error(`${testName} failed: ${errorMessage}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const initializeTests = () => {
|
||||
const tests: TestResult[] = [
|
||||
{ id: 'get-items', name: 'GET /test/items', status: 'pending' },
|
||||
{ id: 'create-item', name: 'POST /test/items', status: 'pending' },
|
||||
{ id: 'get-item-by-id', name: 'GET /test/items/:id', status: 'pending' },
|
||||
{ id: 'update-item', name: 'PUT /test/items/:id', status: 'pending' },
|
||||
{ id: 'delete-item', name: 'DELETE /test/items/:id', status: 'pending' },
|
||||
{ id: 'search-items', name: 'GET /test/search', status: 'pending' },
|
||||
{ id: 'get-stats', name: 'GET /test/stats', status: 'pending' }
|
||||
]
|
||||
|
||||
setTestResults(tests)
|
||||
setTestItems([])
|
||||
setSelectedItem(null)
|
||||
}
|
||||
|
||||
const runAllTests = async () => {
|
||||
if (isRunning) return
|
||||
|
||||
setIsRunning(true)
|
||||
initializeTests()
|
||||
|
||||
try {
|
||||
// Test 1: Get test items list
|
||||
const itemsResponse = await runTest('get-items', 'Get Test Items List', () => dataApiService.get('/test/items'))
|
||||
setTestItems(itemsResponse.items || [])
|
||||
|
||||
// Test 2: Create a new test item
|
||||
const newItem = {
|
||||
title: `Test Item ${Date.now()}`,
|
||||
description: 'This is a test item created by the DataApi test suite',
|
||||
type: 'data',
|
||||
status: 'active',
|
||||
priority: 'medium',
|
||||
tags: ['test', 'api'],
|
||||
metadata: { source: 'test-suite', version: '1.0.0' }
|
||||
}
|
||||
|
||||
const createResponse = await runTest('create-item', 'Create New Test Item', () =>
|
||||
dataApiService.post('/test/items', { body: newItem })
|
||||
)
|
||||
|
||||
// Debug: Log the create response
|
||||
logger.info('Create response received', { createResponse, id: createResponse?.id })
|
||||
|
||||
// Update items list with new item
|
||||
if (createResponse) {
|
||||
setTestItems((prev) => [createResponse, ...prev])
|
||||
setSelectedItem(createResponse)
|
||||
}
|
||||
|
||||
// Test 3: Get item by ID
|
||||
if (createResponse?.id) {
|
||||
logger.info('About to test get item by ID', { id: createResponse.id })
|
||||
await runTest('get-item-by-id', 'Get Test Item By ID', () =>
|
||||
dataApiService.get(`/test/items/${createResponse.id}`)
|
||||
)
|
||||
} else {
|
||||
logger.warn('Skipping get item by ID test - no valid item ID from create response', { createResponse })
|
||||
updateTestResult('get-item-by-id', {
|
||||
status: 'error',
|
||||
error: 'No valid item ID from create response'
|
||||
})
|
||||
}
|
||||
|
||||
// Only proceed with update/delete if we have a valid ID
|
||||
if (createResponse?.id) {
|
||||
// Test 4: Update item
|
||||
const updatedData = {
|
||||
title: `${createResponse.title} (Updated)`,
|
||||
description: `${createResponse.description}\n\nUpdated by test at ${new Date().toISOString()}`,
|
||||
priority: 'high'
|
||||
}
|
||||
|
||||
const updateResponse = await runTest('update-item', 'Update Test Item', () =>
|
||||
dataApiService.put(`/test/items/${createResponse.id}`, {
|
||||
body: updatedData
|
||||
})
|
||||
)
|
||||
|
||||
if (updateResponse) {
|
||||
setSelectedItem(updateResponse)
|
||||
setTestItems((prev) => prev.map((item) => (item.id === createResponse.id ? updateResponse : item)))
|
||||
}
|
||||
}
|
||||
|
||||
// Test 5: Search items
|
||||
await runTest('search-items', 'Search Test Items', () =>
|
||||
dataApiService.get('/test/search', {
|
||||
query: {
|
||||
query: 'test',
|
||||
page: 1,
|
||||
limit: 10
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Test 6: Get statistics
|
||||
await runTest('get-stats', 'Get Test Statistics', () => dataApiService.get('/test/stats'))
|
||||
|
||||
// Test 7: Delete item (optional, comment out to keep test data)
|
||||
// if (createResponse?.id) {
|
||||
// await runTest(
|
||||
// 'delete-item',
|
||||
// 'Delete Test Item',
|
||||
// () => dataApiService.delete(`/test/items/${createResponse.id}`)
|
||||
// )
|
||||
// }
|
||||
|
||||
message.success('All basic tests completed!')
|
||||
} catch (error) {
|
||||
logger.error('Test suite failed', error as Error)
|
||||
message.error('Test suite execution failed')
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runSingleTest = async (testId: string) => {
|
||||
if (isRunning) return
|
||||
|
||||
switch (testId) {
|
||||
case 'create-item': {
|
||||
if (!newItemTitle.trim()) {
|
||||
message.warning('Please enter an item title')
|
||||
return
|
||||
}
|
||||
|
||||
const createResponse = await runTest(testId, 'Create New Item', () =>
|
||||
dataApiService.post('/test/items', {
|
||||
body: {
|
||||
title: newItemTitle,
|
||||
description: newItemDescription,
|
||||
type: newItemType
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
if (createResponse) {
|
||||
setTestItems((prev) => [createResponse, ...prev])
|
||||
setNewItemTitle('')
|
||||
setNewItemDescription('')
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
message.warning(`Single test execution not implemented for ${testId}`)
|
||||
}
|
||||
}
|
||||
|
||||
const resetTests = () => {
|
||||
setTestResults([])
|
||||
setTestItems([])
|
||||
setSelectedItem(null)
|
||||
setNewItemTitle('')
|
||||
setNewItemDescription('')
|
||||
message.info('Tests reset')
|
||||
}
|
||||
|
||||
const resetTestData = async () => {
|
||||
try {
|
||||
await dataApiService.post('/test/reset', {})
|
||||
message.success('Test data reset successfully')
|
||||
setTestItems([])
|
||||
setSelectedItem(null)
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset test data', error as Error)
|
||||
message.error('Failed to reset test data')
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: TestResult['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'error':
|
||||
return 'error'
|
||||
case 'running':
|
||||
return 'processing'
|
||||
default:
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: TestResult['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <Check size={16} />
|
||||
case 'error':
|
||||
return <X size={16} />
|
||||
case 'running':
|
||||
return <RotateCcw size={16} className="animate-spin" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
title: 'Test',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (name: string) => <Text code>{name}</Text>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: TestResult['status']) => (
|
||||
<Tag color={getStatusColor(status)} icon={getStatusIcon(status)}>
|
||||
{status.toUpperCase()}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Duration',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
render: (duration?: number) => (duration ? `${duration}ms` : '-')
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: any, record: TestResult) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<Play size={14} />}
|
||||
onClick={() => runSingleTest(record.id)}
|
||||
disabled={isRunning}>
|
||||
Run
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
const itemsColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
render: (id: string) => <Text code>{id.substring(0, 20)}...</Text>
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
render: (type: string) => <Tag>{type}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => <Tag color={status === 'active' ? 'green' : 'orange'}>{status}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Priority',
|
||||
dataIndex: 'priority',
|
||||
key: 'priority',
|
||||
render: (priority: string) => (
|
||||
<Tag color={priority === 'high' ? 'red' : priority === 'medium' ? 'orange' : 'blue'}>{priority}</Tag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: any, record: TestItem) => (
|
||||
<Button size="small" type="link" onClick={() => setSelectedItem(record)}>
|
||||
View
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Control Panel */}
|
||||
<Card size="small">
|
||||
<Row gutter={16} align="middle">
|
||||
<Col span={12}>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Play size={16} />}
|
||||
onClick={runAllTests}
|
||||
loading={isRunning}
|
||||
disabled={isRunning}>
|
||||
Run All Basic Tests
|
||||
</Button>
|
||||
<Button icon={<RotateCcw size={16} />} onClick={resetTests} disabled={isRunning}>
|
||||
Reset Tests
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Space style={{ float: 'right' }}>
|
||||
<Button icon={<Database size={16} />} onClick={resetTestData} disabled={isRunning}>
|
||||
Reset Test Data
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Test Results */}
|
||||
<Card title="Test Results" size="small">
|
||||
{testResults.length === 0 ? (
|
||||
<Alert
|
||||
message="No tests executed yet"
|
||||
description="Click 'Run All Basic Tests' to start testing basic CRUD operations"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={testResults}
|
||||
columns={tableColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Manual Testing */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="Create Item Manually" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Item Title"
|
||||
value={newItemTitle}
|
||||
onChange={(e) => setNewItemTitle(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="Item Description (optional)"
|
||||
value={newItemDescription}
|
||||
onChange={(e) => setNewItemDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<Input placeholder="Item Type" value={newItemType} onChange={(e) => setNewItemType(e.target.value)} />
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => runSingleTest('create-item')}
|
||||
disabled={isRunning || !newItemTitle.trim()}
|
||||
block>
|
||||
Create Item
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card title="Selected Item Details" size="small">
|
||||
{selectedItem ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<strong>ID:</strong> <Text code>{selectedItem.id}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Title:</strong> {selectedItem.title}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Type:</strong> <Tag>{selectedItem.type}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong>{' '}
|
||||
<Tag color={selectedItem.status === 'active' ? 'green' : 'orange'}>{selectedItem.status}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Priority:</strong>{' '}
|
||||
<Tag
|
||||
color={
|
||||
selectedItem.priority === 'high'
|
||||
? 'red'
|
||||
: selectedItem.priority === 'medium'
|
||||
? 'orange'
|
||||
: 'blue'
|
||||
}>
|
||||
{selectedItem.priority}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Created:</strong> {new Date(selectedItem.createdAt).toLocaleString()}
|
||||
</div>
|
||||
{selectedItem.description && (
|
||||
<div>
|
||||
<strong>Description:</strong>
|
||||
<Paragraph ellipsis={{ rows: 3, expandable: true }}>{selectedItem.description}</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
{selectedItem.tags && selectedItem.tags.length > 0 && (
|
||||
<div>
|
||||
<strong>Tags:</strong>{' '}
|
||||
{selectedItem.tags.map((tag: string) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">No item selected</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Items Table */}
|
||||
<Card title={`Test Items (${testItems.length})`} size="small">
|
||||
{testItems.length === 0 ? (
|
||||
<Alert
|
||||
message="No items loaded"
|
||||
description="Run the tests or create a new item to see data here"
|
||||
type="info"
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
dataSource={testItems}
|
||||
columns={itemsColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 10 }}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Test Details */}
|
||||
{testResults.some((t) => t.response || t.error) && (
|
||||
<Card title="Test Details" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{testResults.map(
|
||||
(result) =>
|
||||
(result.response || result.error) && (
|
||||
<div key={result.id}>
|
||||
<Divider orientation="left" plain>
|
||||
<Text code>{result.name}</Text> -
|
||||
<Tag color={getStatusColor(result.status)}>{result.status}</Tag>
|
||||
</Divider>
|
||||
{result.response && (
|
||||
<div>
|
||||
<Text strong>Response:</Text>
|
||||
<pre
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{JSON.stringify(result.response, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{result.error && (
|
||||
<div>
|
||||
<Text strong type="danger">
|
||||
Error:
|
||||
</Text>
|
||||
<pre
|
||||
style={{
|
||||
background: '#fff2f0',
|
||||
padding: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
overflow: 'auto',
|
||||
border: '1px solid #ffccc7'
|
||||
}}>
|
||||
{result.error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div`
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 8px 0;
|
||||
max-height: 200px;
|
||||
}
|
||||
`
|
||||
|
||||
export default DataApiBasicTests
|
||||
@ -1,658 +0,0 @@
|
||||
import { prefetch, useInvalidateCache, useMutation, usePaginatedQuery, useQuery } from '@renderer/data/hooks/useDataApi'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { Alert, Button, Card, Col, Input, message, Row, Space, Spin, Table, Tag, Typography } from 'antd'
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Database,
|
||||
Edit,
|
||||
Eye,
|
||||
GitBranch,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Zap
|
||||
} from 'lucide-react'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
const { TextArea } = Input
|
||||
|
||||
const logger = loggerService.withContext('DataApiHookTests')
|
||||
|
||||
interface TestItem {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
type: string
|
||||
priority: string
|
||||
tags: string[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
metadata: Record<string, any>
|
||||
}
|
||||
|
||||
const DataApiHookTests: React.FC = () => {
|
||||
// Hook for cache invalidation
|
||||
const invalidateCache = useInvalidateCache()
|
||||
|
||||
// State for manual testing
|
||||
const [selectedItemId, setSelectedItemId] = useState<string>('')
|
||||
const [newItemTitle, setNewItemTitle] = useState('')
|
||||
const [newItemDescription, setNewItemDescription] = useState('')
|
||||
const [updateTitle, setUpdateTitle] = useState('')
|
||||
const [updateDescription, setUpdateDescription] = useState('')
|
||||
const [queryParams, setQueryParams] = useState({ page: 1, limit: 5 })
|
||||
const [cacheTestCount, setCacheTestCount] = useState(0)
|
||||
|
||||
// useQuery hook test - Basic data fetching
|
||||
const {
|
||||
data: itemsData,
|
||||
loading: itemsLoading,
|
||||
error: itemsError,
|
||||
refetch: refreshItems
|
||||
} = useQuery('/test/items' as any, {
|
||||
query: queryParams,
|
||||
swrOptions: {
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 2000
|
||||
}
|
||||
})
|
||||
|
||||
// useQuery hook test - Single item by ID
|
||||
const {
|
||||
data: singleItem,
|
||||
loading: singleItemLoading,
|
||||
error: singleItemError,
|
||||
refetch: refreshSingleItem
|
||||
} = useQuery(selectedItemId ? `/test/items/${selectedItemId}` : (null as any), {
|
||||
enabled: !!selectedItemId,
|
||||
swrOptions: {
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
})
|
||||
|
||||
// usePaginatedQuery hook test
|
||||
const {
|
||||
items: paginatedItems,
|
||||
total: totalItems,
|
||||
page: currentPage,
|
||||
loading: paginatedLoading,
|
||||
error: paginatedError,
|
||||
hasMore,
|
||||
hasPrev,
|
||||
prevPage,
|
||||
nextPage,
|
||||
refresh: refreshPaginated,
|
||||
reset: resetPagination
|
||||
} = usePaginatedQuery('/test/items' as any, {
|
||||
query: { type: 'test' },
|
||||
limit: 3,
|
||||
swrOptions: {
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
})
|
||||
|
||||
// useMutation hook tests
|
||||
const createItemMutation = useMutation('POST', '/test/items', {
|
||||
onSuccess: (data) => {
|
||||
logger.info('Item created successfully', { data })
|
||||
message.success('Item created!')
|
||||
setNewItemTitle('')
|
||||
setNewItemDescription('')
|
||||
// Invalidate cache to refresh the list
|
||||
invalidateCache(['/test/items'])
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to create item', error as Error)
|
||||
message.error(`Failed to create item: ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
const updateItemMutation = useMutation(
|
||||
'PUT',
|
||||
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
logger.info('Item updated successfully', { data })
|
||||
message.success('Item updated!')
|
||||
setUpdateTitle('')
|
||||
setUpdateDescription('')
|
||||
// Invalidate specific item and items list
|
||||
invalidateCache(['/test/items', ...(selectedItemId ? [`/test/items/${selectedItemId}`] : [])])
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to update item', error as Error)
|
||||
message.error(`Failed to update item: ${error.message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const deleteItemMutation = useMutation(
|
||||
'DELETE',
|
||||
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
|
||||
{
|
||||
onSuccess: () => {
|
||||
logger.info('Item deleted successfully')
|
||||
message.success('Item deleted!')
|
||||
setSelectedItemId('')
|
||||
// Invalidate cache
|
||||
invalidateCache(['/test/items'])
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error('Failed to delete item', error as Error)
|
||||
message.error(`Failed to delete item: ${error.message}`)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// useMutation with optimistic updates
|
||||
const optimisticUpdateMutation = useMutation(
|
||||
'PUT',
|
||||
selectedItemId ? `/test/items/${selectedItemId}` : '/test/items/placeholder',
|
||||
{
|
||||
optimistic: true,
|
||||
optimisticData: { title: 'Optimistically Updated Item' },
|
||||
revalidate: ['/test/items']
|
||||
}
|
||||
)
|
||||
|
||||
// Handle functions
|
||||
const handleCreateItem = useCallback(async () => {
|
||||
if (!newItemTitle.trim()) {
|
||||
message.warning('Please enter an item title')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createItemMutation.mutate({
|
||||
body: {
|
||||
title: newItemTitle,
|
||||
description: newItemDescription,
|
||||
type: 'hook-test'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Error already handled by mutation
|
||||
}
|
||||
}, [newItemTitle, newItemDescription, createItemMutation])
|
||||
|
||||
const handleUpdateTopic = useCallback(async () => {
|
||||
if (!selectedItemId || !updateTitle.trim()) {
|
||||
message.warning('Please select an item and enter a title')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateItemMutation.mutate({
|
||||
body: {
|
||||
title: updateTitle,
|
||||
description: updateDescription
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// Error already handled by mutation
|
||||
}
|
||||
}, [selectedItemId, updateTitle, updateDescription, updateItemMutation])
|
||||
|
||||
const handleDeleteTopic = useCallback(async () => {
|
||||
if (!selectedItemId) {
|
||||
message.warning('Please select an item to delete')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteItemMutation.mutate()
|
||||
} catch (error) {
|
||||
// Error already handled by mutation
|
||||
}
|
||||
}, [selectedItemId, deleteItemMutation])
|
||||
|
||||
const handleOptimisticUpdate = useCallback(async () => {
|
||||
if (!selectedItemId || !singleItem) {
|
||||
message.warning('Please select an item for optimistic update')
|
||||
return
|
||||
}
|
||||
|
||||
const optimisticData = {
|
||||
...singleItem,
|
||||
title: `${(singleItem as any)?.title || 'Unknown'} (Optimistic Update)`,
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
try {
|
||||
await optimisticUpdateMutation.mutate({
|
||||
body: {
|
||||
title: optimisticData.title,
|
||||
description: (singleItem as any)?.description
|
||||
}
|
||||
})
|
||||
message.success('Optimistic update completed!')
|
||||
} catch (error) {
|
||||
message.error(`Optimistic update failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
}
|
||||
}, [selectedItemId, singleItem, optimisticUpdateMutation])
|
||||
|
||||
const handlePrefetch = useCallback(async () => {
|
||||
try {
|
||||
// Prefetch the next page of items
|
||||
await prefetch('/test/items', {
|
||||
query: { page: queryParams.page + 1, limit: queryParams.limit }
|
||||
})
|
||||
message.success('Next page prefetched!')
|
||||
} catch (error) {
|
||||
message.error('Prefetch failed')
|
||||
}
|
||||
}, [queryParams])
|
||||
|
||||
const handleCacheTest = useCallback(async () => {
|
||||
// Test cache invalidation and refresh
|
||||
setCacheTestCount((prev) => prev + 1)
|
||||
|
||||
if (cacheTestCount % 3 === 0) {
|
||||
await invalidateCache()
|
||||
message.info('All cache invalidated')
|
||||
} else if (cacheTestCount % 3 === 1) {
|
||||
await invalidateCache('/test/items')
|
||||
message.info('Topics cache invalidated')
|
||||
} else {
|
||||
refreshItems()
|
||||
message.info('Topics refreshed')
|
||||
}
|
||||
}, [cacheTestCount, refreshItems, invalidateCache])
|
||||
|
||||
const itemsColumns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
render: (id: string) => (
|
||||
<Text code style={{ fontSize: 11 }}>
|
||||
{id.substring(0, 15)}...
|
||||
</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
render: (type: string) => <Tag>{type}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => <Tag color={status === 'active' ? 'green' : 'orange'}>{status}</Tag>
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
render: (_: any, record: TestItem) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<Eye size={12} />}
|
||||
onClick={() => {
|
||||
setSelectedItemId(record.id)
|
||||
setUpdateTitle(record.title)
|
||||
setUpdateDescription((record as any).description || '')
|
||||
}}>
|
||||
Select
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Hook Status Overview */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||
<Database size={20} />
|
||||
<Text strong>useQuery</Text>
|
||||
{itemsLoading ? (
|
||||
<Spin size="small" />
|
||||
) : itemsError ? (
|
||||
<Tag color="red">Error</Tag>
|
||||
) : (
|
||||
<Tag color="green">Active</Tag>
|
||||
)}
|
||||
<Text type="secondary">{(itemsData as any)?.items?.length || 0} items</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||
<TrendingUp size={20} />
|
||||
<Text strong>usePaginated</Text>
|
||||
{paginatedLoading ? (
|
||||
<Spin size="small" />
|
||||
) : paginatedError ? (
|
||||
<Tag color="red">Error</Tag>
|
||||
) : (
|
||||
<Tag color="green">Active</Tag>
|
||||
)}
|
||||
<Text type="secondary">
|
||||
{paginatedItems.length} / {totalItems}
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||
<GitBranch size={20} />
|
||||
<Text strong>useMutation</Text>
|
||||
{createItemMutation.loading || updateItemMutation.loading || deleteItemMutation.loading ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Tag color="blue">Ready</Tag>
|
||||
)}
|
||||
<Text type="secondary">CRUD Operations</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Space direction="vertical" align="center" style={{ width: '100%' }}>
|
||||
<Zap size={20} />
|
||||
<Text strong>Cache</Text>
|
||||
<Tag color="purple">Active</Tag>
|
||||
<Text type="secondary">Test #{cacheTestCount}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* useQuery Test - Basic Topics List */}
|
||||
<Card title="useQuery Hook Test - Topics List" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space>
|
||||
<Text>Page: {queryParams.page}</Text>
|
||||
<Text>Limit: {queryParams.limit}</Text>
|
||||
{itemsLoading && <Spin size="small" />}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button size="small" icon={<RefreshCw size={14} />} onClick={refreshItems} loading={itemsLoading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="small" onClick={handlePrefetch}>
|
||||
Prefetch Next
|
||||
</Button>
|
||||
<Button size="small" onClick={handleCacheTest}>
|
||||
Cache Test
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{itemsError ? (
|
||||
<Alert message="useQuery Error" description={itemsError.message} type="error" showIcon />
|
||||
) : (
|
||||
<Table
|
||||
dataSource={(itemsData as any)?.items || []}
|
||||
columns={itemsColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={itemsLoading}
|
||||
scroll={{ x: true }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Row justify="space-between">
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={queryParams.page <= 1}
|
||||
onClick={() => setQueryParams((prev) => ({ ...prev, page: prev.page - 1 }))}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setQueryParams((prev) => ({ ...prev, page: prev.page + 1 }))}>
|
||||
Next
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary">Total: {(itemsData as any)?.total || 0} items</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Single Topic Query */}
|
||||
<Card title="useQuery Hook Test - Single Topic" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Enter item ID"
|
||||
value={selectedItemId}
|
||||
onChange={(e) => setSelectedItemId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
icon={<RefreshCw size={14} />}
|
||||
onClick={refreshSingleItem}
|
||||
loading={singleItemLoading}
|
||||
disabled={!selectedItemId}
|
||||
block>
|
||||
Fetch Topic
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
{singleItemLoading ? (
|
||||
<Spin />
|
||||
) : singleItemError ? (
|
||||
<Alert message="Error" description={singleItemError.message} type="error" showIcon />
|
||||
) : singleItem ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<div>
|
||||
<strong>Title:</strong> {(singleItem as any)?.title || 'N/A'}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Type:</strong> <Tag>{(singleItem as any)?.type || 'N/A'}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Status:</strong>{' '}
|
||||
<Tag color={(singleItem as any)?.status === 'active' ? 'green' : 'orange'}>
|
||||
{(singleItem as any)?.status || 'N/A'}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Updated:</strong> {new Date((singleItem as any)?.updatedAt || Date.now()).toLocaleString()}
|
||||
</div>
|
||||
</Space>
|
||||
) : (
|
||||
<Text type="secondary">No item selected</Text>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* usePaginatedQuery Test */}
|
||||
<Card title="usePaginatedQuery Hook Test" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space>
|
||||
<Text>Page: {currentPage}</Text>
|
||||
<Text>Items: {paginatedItems.length}</Text>
|
||||
<Text>Total: {totalItems}</Text>
|
||||
{paginatedLoading && <Spin size="small" />}
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button size="small" icon={<ArrowLeft size={14} />} onClick={prevPage} disabled={!hasPrev}>
|
||||
Previous
|
||||
</Button>
|
||||
<Button size="small" icon={<ArrowRight size={14} />} onClick={nextPage} disabled={!hasMore}>
|
||||
{hasMore ? 'Load More' : 'No More'}
|
||||
</Button>
|
||||
<Button size="small" icon={<RefreshCw size={14} />} onClick={refreshPaginated}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="small" onClick={resetPagination}>
|
||||
Reset
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{paginatedError ? (
|
||||
<Alert message="usePaginatedQuery Error" description={paginatedError.message} type="error" showIcon />
|
||||
) : (
|
||||
<div style={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{paginatedItems.map((item, index) => {
|
||||
const typedItem = item as any
|
||||
return (
|
||||
<Card key={typedItem.id} size="small" style={{ marginBottom: 8 }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col span={18}>
|
||||
<Space direction="vertical" size="small">
|
||||
<Text strong>{typedItem.title}</Text>
|
||||
<Space>
|
||||
<Tag>{typedItem.type}</Tag>
|
||||
<Tag color={typedItem.status === 'active' ? 'green' : 'orange'}>{typedItem.status}</Tag>
|
||||
</Space>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Text type="secondary">#{index + 1}</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* useMutation Tests */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="useMutation - Create Topic" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
placeholder="Topic Title"
|
||||
value={newItemTitle}
|
||||
onChange={(e) => setNewItemTitle(e.target.value)}
|
||||
/>
|
||||
<TextArea
|
||||
placeholder="Topic Content (optional)"
|
||||
value={newItemDescription}
|
||||
onChange={(e) => setNewItemDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={handleCreateItem}
|
||||
loading={createItemMutation.loading}
|
||||
disabled={!newItemTitle.trim()}
|
||||
block>
|
||||
Create Topic
|
||||
</Button>
|
||||
{createItemMutation.error && (
|
||||
<Alert
|
||||
message="Creation Error"
|
||||
description={createItemMutation.error.message}
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Card title="useMutation - Update Topic" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input placeholder="New Title" value={updateTitle} onChange={(e) => setUpdateTitle(e.target.value)} />
|
||||
<TextArea
|
||||
placeholder="New Content"
|
||||
value={updateDescription}
|
||||
onChange={(e) => setUpdateDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
<Space style={{ width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Edit size={14} />}
|
||||
onClick={handleUpdateTopic}
|
||||
loading={updateItemMutation.loading}
|
||||
disabled={!selectedItemId || !updateTitle.trim()}>
|
||||
Update
|
||||
</Button>
|
||||
<Button icon={<Zap size={14} />} onClick={handleOptimisticUpdate} disabled={!selectedItemId}>
|
||||
Optimistic
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
icon={<Trash2 size={14} />}
|
||||
onClick={handleDeleteTopic}
|
||||
loading={deleteItemMutation.loading}
|
||||
disabled={!selectedItemId}>
|
||||
Delete
|
||||
</Button>
|
||||
</Space>
|
||||
{(updateItemMutation.error || deleteItemMutation.error) && (
|
||||
<Alert
|
||||
message="Operation Error"
|
||||
description={(updateItemMutation.error || deleteItemMutation.error)?.message}
|
||||
type="error"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div`
|
||||
.ant-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ant-table-small .ant-table-tbody > tr > td {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.ant-tag {
|
||||
border-radius: 4px;
|
||||
}
|
||||
`
|
||||
|
||||
export default DataApiHookTests
|
||||
@ -1,758 +0,0 @@
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
InputNumber,
|
||||
message,
|
||||
Progress,
|
||||
Row,
|
||||
Space,
|
||||
Statistic,
|
||||
Table,
|
||||
Tag,
|
||||
Typography
|
||||
} from 'antd'
|
||||
import { Activity, AlertTriangle, Cpu, Gauge, RefreshCw, StopCircle, Timer, TrendingUp, Zap } from 'lucide-react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { dataApiService } from '../../../data/DataApiService'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const logger = loggerService.withContext('DataApiStressTests')
|
||||
|
||||
interface StressTestConfig {
|
||||
concurrentRequests: number
|
||||
totalRequests: number
|
||||
requestDelay: number
|
||||
testDuration: number // in seconds
|
||||
errorRate: number // percentage of requests that should fail
|
||||
}
|
||||
|
||||
interface StressTestResult {
|
||||
id: string
|
||||
requestId: number
|
||||
startTime: number
|
||||
endTime?: number
|
||||
duration?: number
|
||||
status: 'pending' | 'success' | 'error' | 'timeout'
|
||||
response?: any
|
||||
error?: string
|
||||
memoryBefore?: number
|
||||
memoryAfter?: number
|
||||
}
|
||||
|
||||
interface StressTestSummary {
|
||||
totalRequests: number
|
||||
completedRequests: number
|
||||
successfulRequests: number
|
||||
failedRequests: number
|
||||
timeoutRequests: number
|
||||
averageResponseTime: number
|
||||
minResponseTime: number
|
||||
maxResponseTime: number
|
||||
requestsPerSecond: number
|
||||
errorRate: number
|
||||
memoryUsage: {
|
||||
initial: number
|
||||
peak: number
|
||||
final: number
|
||||
leaked: number
|
||||
}
|
||||
testDuration: number
|
||||
}
|
||||
|
||||
const DataApiStressTests: React.FC = () => {
|
||||
const [config, setConfig] = useState<StressTestConfig>({
|
||||
concurrentRequests: 10,
|
||||
totalRequests: 100,
|
||||
requestDelay: 100,
|
||||
testDuration: 60,
|
||||
errorRate: 10
|
||||
})
|
||||
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [currentTest, setCurrentTest] = useState<string>('')
|
||||
const [results, setResults] = useState<StressTestResult[]>([])
|
||||
const [summary, setSummary] = useState<StressTestSummary | null>(null)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [realtimeStats, setRealtimeStats] = useState({
|
||||
rps: 0,
|
||||
avgResponseTime: 0,
|
||||
errorRate: 0,
|
||||
memoryUsage: 0
|
||||
})
|
||||
|
||||
// Test control
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const testStartTimeRef = useRef<number>(0)
|
||||
const completedCountRef = useRef<number>(0)
|
||||
const statsIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Memory monitoring
|
||||
const getMemoryUsage = () => {
|
||||
if ((performance as any).memory) {
|
||||
return (performance as any).memory.usedJSHeapSize
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Update realtime statistics
|
||||
const updateRealtimeStats = useCallback(() => {
|
||||
const completedResults = results.filter((r) => r.status !== 'pending')
|
||||
const errorResults = completedResults.filter((r) => r.status === 'error')
|
||||
|
||||
if (completedResults.length === 0) return
|
||||
|
||||
const durations = completedResults.map((r) => r.duration || 0).filter((d) => d > 0)
|
||||
const avgResponseTime = durations.length > 0 ? durations.reduce((sum, d) => sum + d, 0) / durations.length : 0
|
||||
|
||||
const elapsedTime = (Date.now() - testStartTimeRef.current) / 1000
|
||||
const rps = elapsedTime > 0 ? completedResults.length / elapsedTime : 0
|
||||
const errorRate = completedResults.length > 0 ? (errorResults.length / completedResults.length) * 100 : 0
|
||||
|
||||
setRealtimeStats({
|
||||
rps: Math.round(rps * 100) / 100,
|
||||
avgResponseTime: Math.round(avgResponseTime),
|
||||
errorRate: Math.round(errorRate * 100) / 100,
|
||||
memoryUsage: Math.round((getMemoryUsage() / 1024 / 1024) * 100) / 100 // MB
|
||||
})
|
||||
}, [results])
|
||||
|
||||
// Update statistics every second during testing
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
statsIntervalRef.current = setInterval(updateRealtimeStats, 1000)
|
||||
} else if (statsIntervalRef.current) {
|
||||
clearInterval(statsIntervalRef.current)
|
||||
statsIntervalRef.current = null
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (statsIntervalRef.current) {
|
||||
clearInterval(statsIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [isRunning, updateRealtimeStats])
|
||||
|
||||
const executeStressTest = async (testName: string, testFn: (requestId: number) => Promise<any>) => {
|
||||
setIsRunning(true)
|
||||
setCurrentTest(testName)
|
||||
setResults([])
|
||||
setSummary(null)
|
||||
setProgress(0)
|
||||
completedCountRef.current = 0
|
||||
testStartTimeRef.current = Date.now()
|
||||
|
||||
// Create abort controller
|
||||
abortControllerRef.current = new AbortController()
|
||||
const { signal } = abortControllerRef.current
|
||||
|
||||
const initialMemory = getMemoryUsage()
|
||||
let peakMemory = initialMemory
|
||||
|
||||
logger.info(`Starting stress test: ${testName}`, config)
|
||||
|
||||
try {
|
||||
const testResults: StressTestResult[] = []
|
||||
|
||||
// Initialize pending results
|
||||
for (let i = 0; i < config.totalRequests; i++) {
|
||||
testResults.push({
|
||||
id: `request-${i}`,
|
||||
requestId: i,
|
||||
startTime: 0,
|
||||
status: 'pending'
|
||||
})
|
||||
}
|
||||
setResults([...testResults])
|
||||
|
||||
// Execute requests with controlled concurrency
|
||||
const executeRequest = async (requestId: number): Promise<void> => {
|
||||
if (signal.aborted) return
|
||||
|
||||
const startTime = Date.now()
|
||||
const memoryBefore = getMemoryUsage()
|
||||
|
||||
// Update memory peak
|
||||
peakMemory = Math.max(peakMemory, memoryBefore)
|
||||
|
||||
testResults[requestId].startTime = startTime
|
||||
testResults[requestId].memoryBefore = memoryBefore
|
||||
setResults([...testResults])
|
||||
|
||||
try {
|
||||
const response = await testFn(requestId)
|
||||
const endTime = Date.now()
|
||||
const duration = endTime - startTime
|
||||
const memoryAfter = getMemoryUsage()
|
||||
|
||||
testResults[requestId] = {
|
||||
...testResults[requestId],
|
||||
endTime,
|
||||
duration,
|
||||
status: 'success',
|
||||
response,
|
||||
memoryAfter
|
||||
}
|
||||
|
||||
completedCountRef.current++
|
||||
setProgress((completedCountRef.current / config.totalRequests) * 100)
|
||||
} catch (error) {
|
||||
const endTime = Date.now()
|
||||
const duration = endTime - startTime
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
|
||||
testResults[requestId] = {
|
||||
...testResults[requestId],
|
||||
endTime,
|
||||
duration,
|
||||
status: errorMessage.includes('timeout') ? 'timeout' : 'error',
|
||||
error: errorMessage,
|
||||
memoryAfter: getMemoryUsage()
|
||||
}
|
||||
|
||||
completedCountRef.current++
|
||||
setProgress((completedCountRef.current / config.totalRequests) * 100)
|
||||
}
|
||||
|
||||
setResults([...testResults])
|
||||
}
|
||||
|
||||
// Control concurrency
|
||||
let activeRequests = 0
|
||||
let nextRequestId = 0
|
||||
|
||||
const processNextRequest = async () => {
|
||||
if (nextRequestId >= config.totalRequests || signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = nextRequestId++
|
||||
activeRequests++
|
||||
|
||||
await executeRequest(requestId)
|
||||
|
||||
activeRequests--
|
||||
|
||||
// Add delay between requests if configured
|
||||
if (config.requestDelay > 0 && !signal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, config.requestDelay))
|
||||
}
|
||||
|
||||
// Continue processing if we haven't reached limits
|
||||
if (activeRequests < config.concurrentRequests && nextRequestId < config.totalRequests) {
|
||||
processNextRequest()
|
||||
}
|
||||
}
|
||||
|
||||
// Start initial concurrent requests
|
||||
const initialRequests = Math.min(config.concurrentRequests, config.totalRequests)
|
||||
for (let i = 0; i < initialRequests; i++) {
|
||||
processNextRequest()
|
||||
}
|
||||
|
||||
// Wait for all requests to complete or timeout
|
||||
const maxWaitTime = config.testDuration * 1000
|
||||
const waitStartTime = Date.now()
|
||||
|
||||
while (completedCountRef.current < config.totalRequests && !signal.aborted) {
|
||||
if (Date.now() - waitStartTime > maxWaitTime) {
|
||||
logger.warn('Stress test timeout reached')
|
||||
break
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// Calculate final summary
|
||||
const finalMemory = getMemoryUsage()
|
||||
const completedResults = testResults.filter((r) => r.status !== 'pending')
|
||||
const failedResults = completedResults.filter((r) => r.status === 'error')
|
||||
const timeoutResults = completedResults.filter((r) => r.status === 'timeout')
|
||||
|
||||
const durations = completedResults.map((r) => r.duration || 0).filter((d) => d > 0)
|
||||
const avgResponseTime = durations.length > 0 ? durations.reduce((sum, d) => sum + d, 0) / durations.length : 0
|
||||
const minResponseTime = durations.length > 0 ? Math.min(...durations) : 0
|
||||
const maxResponseTime = durations.length > 0 ? Math.max(...durations) : 0
|
||||
|
||||
const testDuration = (Date.now() - testStartTimeRef.current) / 1000
|
||||
const requestsPerSecond = testDuration > 0 ? completedResults.length / testDuration : 0
|
||||
const errorRate = completedResults.length > 0 ? (failedResults.length / completedResults.length) * 100 : 0
|
||||
|
||||
const testSummary: StressTestSummary = {
|
||||
totalRequests: config.totalRequests,
|
||||
completedRequests: completedResults.length,
|
||||
successfulRequests: completedResults.filter((r) => r.status === 'success').length,
|
||||
failedRequests: failedResults.length,
|
||||
timeoutRequests: timeoutResults.length,
|
||||
averageResponseTime: Math.round(avgResponseTime),
|
||||
minResponseTime,
|
||||
maxResponseTime,
|
||||
requestsPerSecond: Math.round(requestsPerSecond * 100) / 100,
|
||||
errorRate: Math.round(errorRate * 100) / 100,
|
||||
memoryUsage: {
|
||||
initial: Math.round((initialMemory / 1024 / 1024) * 100) / 100,
|
||||
peak: Math.round((peakMemory / 1024 / 1024) * 100) / 100,
|
||||
final: Math.round((finalMemory / 1024 / 1024) * 100) / 100,
|
||||
leaked: Math.round(((finalMemory - initialMemory) / 1024 / 1024) * 100) / 100
|
||||
},
|
||||
testDuration: Math.round(testDuration * 100) / 100
|
||||
}
|
||||
|
||||
setSummary(testSummary)
|
||||
|
||||
logger.info(`Stress test completed: ${testName}`, testSummary)
|
||||
const successfulCount = completedResults.filter((r) => r.status === 'success').length
|
||||
message.success(`Stress test completed! ${successfulCount}/${config.totalRequests} requests succeeded`)
|
||||
} catch (error) {
|
||||
logger.error('Stress test failed', error as Error)
|
||||
message.error('Stress test failed')
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
setCurrentTest('')
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const runConcurrentRequestsTest = async () => {
|
||||
await executeStressTest('Concurrent Requests Test', async (requestId) => {
|
||||
// Alternate between different endpoints to simulate real usage
|
||||
const endpoints = ['/test/items', '/test/stats'] as const
|
||||
const endpoint = endpoints[requestId % endpoints.length]
|
||||
|
||||
return await dataApiService.get(endpoint)
|
||||
})
|
||||
}
|
||||
|
||||
const runMemoryLeakTest = async () => {
|
||||
await executeStressTest('Memory Leak Test', async (requestId) => {
|
||||
// Test different types of requests to ensure no memory leaks
|
||||
if (requestId % 5 === 0) {
|
||||
// Test slower requests
|
||||
return await dataApiService.post('/test/slow', { body: { delay: 500 } })
|
||||
} else {
|
||||
// Normal request
|
||||
return await dataApiService.get('/test/items')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const runErrorHandlingTest = async () => {
|
||||
await executeStressTest('Error Handling Stress Test', async (requestId) => {
|
||||
// Mix of successful and error requests
|
||||
const errorTypes = ['network', 'server', 'timeout', 'notfound', 'validation']
|
||||
const shouldError = Math.random() * 100 < config.errorRate
|
||||
|
||||
if (shouldError) {
|
||||
const errorType = errorTypes[requestId % errorTypes.length]
|
||||
try {
|
||||
return await dataApiService.post('/test/error', { body: { errorType: errorType as any } })
|
||||
} catch (error) {
|
||||
return { expectedError: true, error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
}
|
||||
} else {
|
||||
return await dataApiService.get('/test/items')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const runMixedOperationsTest = async () => {
|
||||
await executeStressTest('Mixed Operations Stress Test', async (requestId) => {
|
||||
const operations = ['GET', 'POST', 'PUT', 'DELETE']
|
||||
const operation = operations[requestId % operations.length]
|
||||
|
||||
switch (operation) {
|
||||
case 'GET':
|
||||
return await dataApiService.get('/test/items')
|
||||
|
||||
case 'POST':
|
||||
return await dataApiService.post('/test/items', {
|
||||
body: {
|
||||
title: `Stress Test Topic ${requestId}`,
|
||||
description: `Created during stress test #${requestId}`,
|
||||
type: 'stress-test'
|
||||
}
|
||||
})
|
||||
|
||||
case 'PUT':
|
||||
// First get an item, then update it
|
||||
try {
|
||||
const items = await dataApiService.get('/test/items')
|
||||
if ((items as any).items && (items as any).items.length > 0) {
|
||||
const itemId = (items as any).items[0].id
|
||||
return await dataApiService.put(`/test/items/${itemId}`, {
|
||||
body: {
|
||||
title: `Updated by stress test ${requestId}`
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return { updateFailed: true, error: error instanceof Error ? error.message : 'Unknown error' }
|
||||
}
|
||||
return { updateFailed: true, message: 'No items found to update' }
|
||||
|
||||
default:
|
||||
return await dataApiService.get('/test/items')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const stopTest = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
message.info('Stress test stopped by user')
|
||||
}
|
||||
}
|
||||
|
||||
const resetTests = () => {
|
||||
stopTest()
|
||||
setResults([])
|
||||
setSummary(null)
|
||||
setProgress(0)
|
||||
setRealtimeStats({ rps: 0, avgResponseTime: 0, errorRate: 0, memoryUsage: 0 })
|
||||
message.info('Stress tests reset')
|
||||
}
|
||||
|
||||
const resultColumns = [
|
||||
{
|
||||
title: 'Request ID',
|
||||
dataIndex: 'requestId',
|
||||
key: 'requestId',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const colorMap = {
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
timeout: 'orange',
|
||||
pending: 'blue'
|
||||
}
|
||||
return <Tag color={colorMap[status] || 'default'}>{status.toUpperCase()}</Tag>
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Duration (ms)',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
render: (duration?: number) => (duration ? `${duration}ms` : '-')
|
||||
},
|
||||
{
|
||||
title: 'Memory Usage (KB)',
|
||||
key: 'memory',
|
||||
render: (_: any, record: StressTestResult) => {
|
||||
if (record.memoryBefore && record.memoryAfter) {
|
||||
const diff = Math.round((record.memoryAfter - record.memoryBefore) / 1024)
|
||||
return diff > 0 ? `+${diff}` : `${diff}`
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{/* Configuration Panel */}
|
||||
<Card title="Stress Test Configuration" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Text>Concurrent Requests:</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={50}
|
||||
value={config.concurrentRequests}
|
||||
onChange={(value) => setConfig((prev) => ({ ...prev, concurrentRequests: value || 10 }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text>Total Requests:</Text>
|
||||
<InputNumber
|
||||
min={10}
|
||||
max={1000}
|
||||
value={config.totalRequests}
|
||||
onChange={(value) => setConfig((prev) => ({ ...prev, totalRequests: value || 100 }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text>Request Delay (ms):</Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={5000}
|
||||
value={config.requestDelay}
|
||||
onChange={(value) => setConfig((prev) => ({ ...prev, requestDelay: value || 0 }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Text>Error Rate (%):</Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
value={config.errorRate}
|
||||
onChange={(value) => setConfig((prev) => ({ ...prev, errorRate: value || 0 }))}
|
||||
style={{ width: '100%', marginTop: 4 }}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Control Panel */}
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Cpu size={16} />}
|
||||
onClick={runConcurrentRequestsTest}
|
||||
loading={isRunning && currentTest === 'Concurrent Requests Test'}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Concurrent Test
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Activity size={16} />}
|
||||
onClick={runMemoryLeakTest}
|
||||
loading={isRunning && currentTest === 'Memory Leak Test'}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Memory Test
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<AlertTriangle size={16} />}
|
||||
onClick={runErrorHandlingTest}
|
||||
loading={isRunning && currentTest === 'Error Handling Stress Test'}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Error Test
|
||||
</Button>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<Zap size={16} />}
|
||||
onClick={runMixedOperationsTest}
|
||||
loading={isRunning && currentTest === 'Mixed Operations Stress Test'}
|
||||
disabled={isRunning}
|
||||
block>
|
||||
Mixed Test
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Space>
|
||||
<Button danger icon={<StopCircle size={16} />} onClick={stopTest} disabled={!isRunning}>
|
||||
Stop Test
|
||||
</Button>
|
||||
<Button icon={<RefreshCw size={16} />} onClick={resetTests} disabled={isRunning}>
|
||||
Reset
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text type="secondary" style={{ float: 'right' }}>
|
||||
{isRunning && currentTest && `Running: ${currentTest}`}
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Progress and Real-time Stats */}
|
||||
{isRunning && (
|
||||
<Card title="Test Progress" size="small">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Progress percent={Math.round(progress)} status={isRunning ? 'active' : 'success'} showInfo />
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Requests/Second"
|
||||
value={realtimeStats.rps}
|
||||
precision={2}
|
||||
prefix={<TrendingUp size={16} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Avg Response Time"
|
||||
value={realtimeStats.avgResponseTime}
|
||||
suffix="ms"
|
||||
prefix={<Timer size={16} />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Error Rate"
|
||||
value={realtimeStats.errorRate}
|
||||
suffix="%"
|
||||
prefix={<AlertTriangle size={16} />}
|
||||
valueStyle={{ color: realtimeStats.errorRate > 10 ? '#cf1322' : '#3f8600' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="Memory Usage"
|
||||
value={realtimeStats.memoryUsage}
|
||||
suffix="MB"
|
||||
prefix={<Gauge size={16} />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Test Summary */}
|
||||
{summary && (
|
||||
<Card title="Test Summary" size="small">
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Request Statistics">
|
||||
<Space direction="vertical">
|
||||
<Text>
|
||||
Total Requests: <strong>{summary.totalRequests}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Completed: <strong>{summary.completedRequests}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Successful: <strong style={{ color: '#3f8600' }}>{summary.successfulRequests}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Failed: <strong style={{ color: '#cf1322' }}>{summary.failedRequests}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Timeouts: <strong style={{ color: '#d48806' }}>{summary.timeoutRequests}</strong>
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Performance Metrics">
|
||||
<Space direction="vertical">
|
||||
<Text>
|
||||
Test Duration: <strong>{summary.testDuration}s</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Requests/Second: <strong>{summary.requestsPerSecond}</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Avg Response Time: <strong>{summary.averageResponseTime}ms</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Min Response Time: <strong>{summary.minResponseTime}ms</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Max Response Time: <strong>{summary.maxResponseTime}ms</strong>
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col span={8}>
|
||||
<Card size="small" title="Memory Usage">
|
||||
<Space direction="vertical">
|
||||
<Text>
|
||||
Initial: <strong>{summary.memoryUsage.initial}MB</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Peak: <strong>{summary.memoryUsage.peak}MB</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Final: <strong>{summary.memoryUsage.final}MB</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Memory Change:
|
||||
<strong
|
||||
style={{
|
||||
color: summary.memoryUsage.leaked > 5 ? '#cf1322' : '#3f8600'
|
||||
}}>
|
||||
{summary.memoryUsage.leaked > 0 ? '+' : ''}
|
||||
{summary.memoryUsage.leaked}MB
|
||||
</strong>
|
||||
</Text>
|
||||
<Text>
|
||||
Error Rate: <strong>{summary.errorRate}%</strong>
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Results Table */}
|
||||
{results.length > 0 && (
|
||||
<Card
|
||||
title={`Test Results (${results.filter((r) => r.status !== 'pending').length}/${results.length} completed)`}
|
||||
size="small">
|
||||
<Table
|
||||
dataSource={results.slice(-50)} // Show last 50 results to avoid performance issues
|
||||
columns={resultColumns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ y: 400 }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Memory Usage Alert */}
|
||||
{summary && summary.memoryUsage.leaked > 10 && (
|
||||
<Alert
|
||||
message="Potential Memory Leak Detected"
|
||||
description={`Memory usage increased by ${summary.memoryUsage.leaked}MB during the test. This might indicate a memory leak.`}
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</TestContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const TestContainer = styled.div`
|
||||
.ant-statistic-title {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-statistic-content {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ant-progress-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-table-small .ant-table-thead > tr > th {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.ant-table-small .ant-table-tbody > tr > td {
|
||||
padding: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
`
|
||||
|
||||
export default DataApiStressTests
|
||||
@ -1,360 +0,0 @@
|
||||
import { Button, Switch } from '@cherrystudio/ui'
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { type PreferenceKeyType, ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Input, message, Select, Slider, Space, 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>('ui.theme_mode')
|
||||
|
||||
// Use the hook with the selected key
|
||||
const [value, setValue] = usePreference(selectedKey)
|
||||
|
||||
const [inputValue, setInputValue] = useState<string>('')
|
||||
|
||||
// Add theme monitoring for visual changes
|
||||
const [currentTheme] = usePreference('ui.theme_mode')
|
||||
const isDarkTheme = currentTheme === ThemeMode.dark
|
||||
|
||||
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: 'ui.theme_mode', label: 'App Theme Mode', sampleValue: 'ThemeMode.dark', type: 'enum' },
|
||||
{ key: 'app.language', label: 'App Language', sampleValue: 'zh-CN', type: 'enum' },
|
||||
{ key: 'app.spell_check.enabled', label: 'Spell Check', sampleValue: 'true', type: 'boolean' },
|
||||
{ key: 'app.zoom_factor', label: 'Zoom Factor', sampleValue: '1.2', type: 'number', min: 0.5, max: 2.0, step: 0.1 },
|
||||
{ key: 'app.tray.enabled', label: 'Tray Enabled', sampleValue: 'true', type: 'boolean' },
|
||||
{
|
||||
key: 'chat.message.font_size',
|
||||
label: 'Message Font Size',
|
||||
sampleValue: '14',
|
||||
type: 'number',
|
||||
min: 8,
|
||||
max: 72,
|
||||
step: 1
|
||||
},
|
||||
{
|
||||
key: 'feature.selection.action_window_opacity',
|
||||
label: 'Selection Window Opacity',
|
||||
sampleValue: '95',
|
||||
type: 'number',
|
||||
min: 10,
|
||||
max: 100,
|
||||
step: 5
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<TestContainer $isDark={isDarkTheme}>
|
||||
<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 $isDark={isDarkTheme}>
|
||||
<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 onClick={handleSetValue}>设置</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<Text strong>快速操作:</Text>
|
||||
<Space wrap style={{ marginTop: 8 }}>
|
||||
{/* Theme Toggle with Visual Feedback */}
|
||||
{selectedKey === 'ui.theme_mode' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setValue(value === 'ThemeMode.dark' ? 'ThemeMode.light' : 'ThemeMode.dark')}>
|
||||
{isDarkTheme ? '🌙' : '☀️'}
|
||||
切换主题 ({value === 'ThemeMode.dark' ? '→ Light' : '→ Dark'})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Boolean Toggle */}
|
||||
{selectedKey === 'app.spell_check.enabled' && (
|
||||
<Switch isSelected={value === true} onValueChange={(checked) => setValue(checked)} />
|
||||
)}
|
||||
|
||||
{/* Language Switch */}
|
||||
{selectedKey === 'app.language' && (
|
||||
<>
|
||||
<Button size="sm" onClick={() => setValue('zh-CN')}>
|
||||
中文
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setValue('en-US')}>
|
||||
English
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Number Type Sliders */}
|
||||
{(() => {
|
||||
const currentTestCase = testCases.find((tc) => tc.key === selectedKey)
|
||||
if (currentTestCase?.type === 'number') {
|
||||
const numValue =
|
||||
typeof value === 'number'
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? parseFloat(value)
|
||||
: currentTestCase.min || 0
|
||||
const min = currentTestCase.min || 0
|
||||
const max = currentTestCase.max || 100
|
||||
const step = currentTestCase.step || 1
|
||||
|
||||
const getDisplayValue = () => {
|
||||
if (selectedKey === 'app.zoom_factor') {
|
||||
return `${Math.round(numValue * 100)}%`
|
||||
} else if (selectedKey === 'feature.selection.action_window_opacity') {
|
||||
return `${Math.round(numValue)}%`
|
||||
} else {
|
||||
return numValue.toString()
|
||||
}
|
||||
}
|
||||
|
||||
const getMarks = () => {
|
||||
if (selectedKey === 'app.zoom_factor') {
|
||||
return {
|
||||
0.5: '50%',
|
||||
0.8: '80%',
|
||||
1.0: '100%',
|
||||
1.2: '120%',
|
||||
1.5: '150%',
|
||||
2.0: '200%'
|
||||
}
|
||||
} else if (selectedKey === 'chat.message.font_size') {
|
||||
return {
|
||||
8: '8px',
|
||||
12: '12px',
|
||||
14: '14px',
|
||||
16: '16px',
|
||||
18: '18px',
|
||||
24: '24px',
|
||||
36: '36px',
|
||||
72: '72px'
|
||||
}
|
||||
} else if (selectedKey === 'feature.selection.action_window_opacity') {
|
||||
return {
|
||||
10: '10%',
|
||||
30: '30%',
|
||||
50: '50%',
|
||||
70: '70%',
|
||||
90: '90%',
|
||||
100: '100%'
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', marginTop: 8 }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Text>
|
||||
{currentTestCase.label}: <strong>{getDisplayValue()}</strong>
|
||||
</Text>
|
||||
<Slider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={numValue}
|
||||
onChange={(val) => setValue(val)}
|
||||
marks={getMarks()}
|
||||
tooltip={{
|
||||
formatter: (val) => {
|
||||
if (selectedKey === 'app.zoom_factor') {
|
||||
return `${Math.round((val || 0) * 100)}%`
|
||||
} else if (selectedKey === 'feature.selection.action_window_opacity') {
|
||||
return `${Math.round(val || 0)}%`
|
||||
}
|
||||
return val?.toString() || '0'
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', marginBottom: 8 }}
|
||||
/>
|
||||
{selectedKey === 'app.zoom_factor' && (
|
||||
<Space>
|
||||
<Button size="sm" onClick={() => setValue(0.8)}>
|
||||
80%
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setValue(1.0)}>
|
||||
100%
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setValue(1.2)}>
|
||||
120%
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
{selectedKey === 'chat.message.font_size' && (
|
||||
<Space>
|
||||
<Button size="sm" onClick={() => setValue(12)}>
|
||||
Small
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setValue(14)}>
|
||||
Normal
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setValue(16)}>
|
||||
Large
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
{selectedKey === 'feature.selection.action_window_opacity' && (
|
||||
<Space>
|
||||
<Button size="sm" onClick={() => setValue(50)}>
|
||||
50%
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setValue(80)}>
|
||||
80%
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setValue(100)}>
|
||||
100%
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})()}
|
||||
|
||||
{/* Sample Values */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
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<{ $isDark: boolean }>`
|
||||
padding: 16px;
|
||||
background: ${(props) => (props.$isDark ? '#262626' : '#fafafa')};
|
||||
border-radius: 8px;
|
||||
|
||||
.ant-typography {
|
||||
color: ${(props) => (props.$isDark ? '#fff' : 'inherit')} !important;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
background-color: ${(props) => (props.$isDark ? '#1f1f1f' : '#fff')} !important;
|
||||
border-color: ${(props) => (props.$isDark ? '#434343' : '#d9d9d9')} !important;
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')} !important;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: ${(props) => (props.$isDark ? '#1f1f1f' : '#fff')} !important;
|
||||
border-color: ${(props) => (props.$isDark ? '#434343' : '#d9d9d9')} !important;
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')} !important;
|
||||
}
|
||||
`
|
||||
|
||||
const CurrentValueContainer = styled.div<{ $isDark?: boolean }>`
|
||||
padding: 12px;
|
||||
background: ${(props) => (props.$isDark ? '#1f1f1f' : '#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
|
||||
@ -1,188 +0,0 @@
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { preferenceService } from '@renderer/data/PreferenceService'
|
||||
import { loggerService } from '@renderer/services/LoggerService'
|
||||
import { type PreferenceKeyType, ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
import { Button, Card, message, Space, Typography } from 'antd'
|
||||
import React, { useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const logger = loggerService.withContext('PreferenceHookTests')
|
||||
|
||||
/**
|
||||
* Advanced usePreference hook testing component
|
||||
* Tests preloading, service access, and hook behavior
|
||||
*/
|
||||
const PreferenceHookTests: React.FC = () => {
|
||||
const [subscriptionCount, setSubscriptionCount] = useState(0)
|
||||
|
||||
// Test multiple hooks with same key
|
||||
const [theme1] = usePreference('ui.theme_mode')
|
||||
const [theme2] = usePreference('ui.theme_mode')
|
||||
const [language] = usePreference('app.language')
|
||||
|
||||
// Manual preload implementation using useEffect
|
||||
React.useEffect(() => {
|
||||
const preloadKeys: PreferenceKeyType[] = ['ui.theme_mode', 'app.language', 'app.zoom_factor']
|
||||
preferenceService.preload(preloadKeys).catch((error) => {
|
||||
logger.error('Failed to preload preferences:', error as Error)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// 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.subscribeChange('ui.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[] = ['ui.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}`)
|
||||
logger.debug('Cache states:', { cachedStates })
|
||||
} catch (error) {
|
||||
message.error(`预加载失败: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
const testBatchOperations = async () => {
|
||||
try {
|
||||
const keys: PreferenceKeyType[] = ['ui.theme_mode', 'app.language']
|
||||
const result = await preferenceService.getMultipleRaw(keys)
|
||||
|
||||
message.success(`批量获取成功: ${Object.keys(result).length} 项`)
|
||||
logger.debug('Batch get result:', { result })
|
||||
|
||||
// Test batch set
|
||||
await preferenceService.setMultiple({
|
||||
'ui.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('ui.theme_mode')
|
||||
}
|
||||
|
||||
const readTime = performance.now() - start
|
||||
|
||||
// Test rapid writes
|
||||
const writeStart = performance.now()
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await preferenceService.set(
|
||||
'ui.theme_mode',
|
||||
i % 3 === 0 ? ThemeMode.light : i % 3 === 1 ? ThemeMode.dark : ThemeMode.system
|
||||
)
|
||||
}
|
||||
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: ui.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. 在其他测试组件中修改 ui.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
|
||||
@ -1,392 +0,0 @@
|
||||
import { useMultiplePreferences } from '@renderer/data/hooks/usePreference'
|
||||
import { Button, Card, Input, message, Select, Slider, Space, Table, Typography } from 'antd'
|
||||
import type { 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: 'ui.theme_mode',
|
||||
language: 'app.language',
|
||||
zoom: 'app.zoom_factor'
|
||||
},
|
||||
ui: {
|
||||
theme: 'ui.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'
|
||||
},
|
||||
numbers: {
|
||||
zoomFactor: 'app.zoom_factor',
|
||||
fontSize: 'chat.message.font_size',
|
||||
opacity: 'feature.selection.action_window_opacity'
|
||||
},
|
||||
custom: {
|
||||
key1: 'ui.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] = useMultiplePreferences(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 'ui.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
|
||||
case 'chat.message.font_size':
|
||||
sampleUpdates[localKey] = 14 + index * 2
|
||||
break
|
||||
case 'feature.selection.action_window_opacity':
|
||||
sampleUpdates[localKey] = 80 + index * 10
|
||||
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 === 'ui.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: 250 }}>
|
||||
<Option value="basic">基础设置 (theme, language, zoom)</Option>
|
||||
<Option value="ui">UI设置 (theme, zoom, spell)</Option>
|
||||
<Option value="user">用户设置 (tray, userName, devMode)</Option>
|
||||
<Option value="numbers">🎛️ 数值设置 (zoom, fontSize, selection opacity)</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>
|
||||
|
||||
{/* Interactive Slider Controls for Numbers Scenario */}
|
||||
{scenario === 'numbers' && (
|
||||
<Card size="small" title="🎛️ 实时Slider联动控制" style={{ backgroundColor: '#f0f8ff' }}>
|
||||
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||
<Text type="secondary">拖动任一滑块,观察其他窗口中相同偏好设置的实时同步变化</Text>
|
||||
|
||||
{/* Zoom Factor Slider */}
|
||||
<div>
|
||||
<Text strong>
|
||||
缩放因子:{' '}
|
||||
{Math.round(
|
||||
(typeof (values as any).zoomFactor === 'number' ? (values as any).zoomFactor : 1.0) * 100
|
||||
)}
|
||||
%
|
||||
</Text>
|
||||
<Slider
|
||||
min={0.5}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={typeof (values as any).zoomFactor === 'number' ? (values as any).zoomFactor : 1.0}
|
||||
onChange={(val) => updateValues({ zoomFactor: val } as any)}
|
||||
marks={{
|
||||
0.5: '50%',
|
||||
1.0: '100%',
|
||||
1.5: '150%',
|
||||
2.0: '200%'
|
||||
}}
|
||||
tooltip={{ formatter: (val) => `${Math.round((val || 1) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Font Size Slider */}
|
||||
<div>
|
||||
<Text strong>
|
||||
字体大小: {typeof (values as any).fontSize === 'number' ? (values as any).fontSize : 14}px
|
||||
</Text>
|
||||
<Slider
|
||||
min={8}
|
||||
max={72}
|
||||
step={1}
|
||||
value={typeof (values as any).fontSize === 'number' ? (values as any).fontSize : 14}
|
||||
onChange={(val) => updateValues({ fontSize: val } as any)}
|
||||
marks={{
|
||||
8: '8px',
|
||||
12: '12px',
|
||||
16: '16px',
|
||||
24: '24px',
|
||||
36: '36px',
|
||||
72: '72px'
|
||||
}}
|
||||
tooltip={{ formatter: (val) => `${val}px` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selection Window Opacity Slider */}
|
||||
<div>
|
||||
<Text strong>
|
||||
选择窗口透明度:{' '}
|
||||
{Math.round(typeof (values as any).opacity === 'number' ? (values as any).opacity : 100)}%
|
||||
</Text>
|
||||
<Slider
|
||||
min={10}
|
||||
max={100}
|
||||
step={5}
|
||||
value={typeof (values as any).opacity === 'number' ? (values as any).opacity : 100}
|
||||
onChange={(val) => updateValues({ opacity: val } as any)}
|
||||
marks={{
|
||||
10: '10%',
|
||||
30: '30%',
|
||||
50: '50%',
|
||||
70: '70%',
|
||||
90: '90%',
|
||||
100: '100%'
|
||||
}}
|
||||
tooltip={{ formatter: (val) => `${Math.round(val || 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '8px', backgroundColor: '#e6f7ff', borderRadius: '4px' }}>
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
💡 测试提示:同时拖动多个滑块,观察另一个测试窗口中的同步效果!
|
||||
</Text>
|
||||
</div>
|
||||
</Space>
|
||||
</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格式的批量更新数据,例如: {"theme": "dark", "language": "en-US"}'
|
||||
rows={6}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
格式: {"localKey": "newValue", ...} - 使用本地键名,不是完整的偏好设置键名
|
||||
</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 === 'ui.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
|
||||
@ -1,236 +0,0 @@
|
||||
import { usePreference } from '@renderer/data/hooks/usePreference'
|
||||
import { preferenceService } from '@renderer/data/PreferenceService'
|
||||
import { type PreferenceKeyType, ThemeMode } from '@shared/data/preference/preferenceTypes'
|
||||
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>('ui.theme_mode')
|
||||
const [testValue, setTestValue] = useState<string>(ThemeMode.dark)
|
||||
const [getResult, setGetResult] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Theme monitoring for visual changes
|
||||
const [currentTheme] = usePreference('ui.theme_mode')
|
||||
const isDarkTheme = currentTheme === ThemeMode.dark
|
||||
|
||||
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)
|
||||
// Use loadAll to get all preferences at once
|
||||
const result = await preferenceService.preloadAll()
|
||||
setGetResult(`All 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 $isDark={isDarkTheme}>
|
||||
<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}>
|
||||
Load All
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* Result Display */}
|
||||
{getResult !== null && (
|
||||
<ResultContainer $isDark={isDarkTheme}>
|
||||
<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('ui.theme_mode')
|
||||
setTestValue(ThemeMode.dark)
|
||||
}}>
|
||||
Test: ui.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<{ $isDark: boolean }>`
|
||||
padding: 16px;
|
||||
background: ${(props) => (props.$isDark ? '#262626' : '#fafafa')};
|
||||
border-radius: 8px;
|
||||
|
||||
.ant-typography {
|
||||
color: ${(props) => (props.$isDark ? '#fff' : 'inherit')} !important;
|
||||
}
|
||||
|
||||
.ant-input {
|
||||
background-color: ${(props) => (props.$isDark ? '#1f1f1f' : '#fff')} !important;
|
||||
border-color: ${(props) => (props.$isDark ? '#434343' : '#d9d9d9')} !important;
|
||||
color: ${(props) => (props.$isDark ? '#fff' : '#000')} !important;
|
||||
}
|
||||
`
|
||||
|
||||
const ResultContainer = styled.div<{ $isDark?: boolean }>`
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
background: ${(props) => (props.$isDark ? '#1f1f1f' : '#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
|
||||
@ -1,13 +0,0 @@
|
||||
import '@ant-design/v5-patch-for-react-19'
|
||||
import '@renderer/assets/styles/index.css'
|
||||
|
||||
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 />)
|
||||
Loading…
Reference in New Issue
Block a user