mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-20 15:10:59 +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'),
|
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
|
||||||
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
|
||||||
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
|
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
|
||||||
dataRefactorMigrate: resolve(__dirname, 'src/renderer/dataRefactorMigrate.html'),
|
|
||||||
migrationV2: resolve(__dirname, 'src/renderer/migrationV2.html')
|
migrationV2: resolve(__dirname, 'src/renderer/migrationV2.html')
|
||||||
},
|
},
|
||||||
onwarn(warning, warn) {
|
onwarn(warning, warn) {
|
||||||
|
|||||||
@ -140,7 +140,7 @@ export default defineConfig([
|
|||||||
{
|
{
|
||||||
// Component Rules - prevent importing antd components when migration completed
|
// Component Rules - prevent importing antd components when migration completed
|
||||||
files: ['**/*.{ts,tsx,js,jsx}'],
|
files: ['**/*.{ts,tsx,js,jsx}'],
|
||||||
ignores: ['src/renderer/src/windows/dataRefactorTest/**/*.{ts,tsx}'],
|
ignores: [],
|
||||||
rules: {
|
rules: {
|
||||||
// 'no-restricted-imports': [
|
// 'no-restricted-imports': [
|
||||||
// 'error',
|
// '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.active': CacheValueTypes.CacheTopic | null
|
||||||
'topic.renaming': string[]
|
'topic.renaming': string[]
|
||||||
'topic.newly_renamed': 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 = {
|
export const DefaultUseCache: UseCacheSchema = {
|
||||||
@ -70,21 +56,7 @@ export const DefaultUseCache: UseCacheSchema = {
|
|||||||
// Topic management
|
// Topic management
|
||||||
'topic.active': null,
|
'topic.active': null,
|
||||||
'topic.renaming': [],
|
'topic.renaming': [],
|
||||||
'topic.newly_renamed': [],
|
'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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -92,22 +64,10 @@ export const DefaultUseCache: UseCacheSchema = {
|
|||||||
*/
|
*/
|
||||||
export type UseSharedCacheSchema = {
|
export type UseSharedCacheSchema = {
|
||||||
'example-key': string
|
'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 = {
|
export const DefaultUseSharedCache: UseSharedCacheSchema = {
|
||||||
'example-key': 'example default value',
|
'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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -116,24 +76,10 @@ export const DefaultUseSharedCache: UseSharedCacheSchema = {
|
|||||||
*/
|
*/
|
||||||
export type RendererPersistCacheSchema = {
|
export type RendererPersistCacheSchema = {
|
||||||
'example-key': string
|
'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 = {
|
export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
|
||||||
'example-key': 'example default value',
|
'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'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -38,8 +38,8 @@ src/main/data/
|
|||||||
│ ├── seeding/ # Database initialization
|
│ ├── seeding/ # Database initialization
|
||||||
│ └── DbService.ts # Database connection and management
|
│ └── DbService.ts # Database connection and management
|
||||||
│
|
│
|
||||||
├── migrate/ # Data migration system
|
├── migration/ # Data migration system
|
||||||
│ └── dataRefactor/ # v2 data refactoring migration tools
|
│ └── v2/ # v2 data refactoring migration tools
|
||||||
│
|
│
|
||||||
├── CacheService.ts # Infrastructure: Cache management
|
├── CacheService.ts # Infrastructure: Cache management
|
||||||
├── DataApiService.ts # Infrastructure: API coordination
|
├── 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
|
// Initialize CacheService
|
||||||
await cacheService.initialize()
|
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****************/
|
/************FOR TESTING ONLY END****************/
|
||||||
|
|
||||||
// Record current version for tracking
|
// 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