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:
fullex 2025-11-20 23:03:00 +08:00
parent 7bd3e047d2
commit 46f2726a63
27 changed files with 7 additions and 9627 deletions

View File

@ -134,7 +134,6 @@ export default defineConfig({
selectionToolbar: resolve(__dirname, 'src/renderer/selectionToolbar.html'),
selectionAction: resolve(__dirname, 'src/renderer/selectionAction.html'),
traceWindow: resolve(__dirname, 'src/renderer/traceWindow.html'),
dataRefactorMigrate: resolve(__dirname, 'src/renderer/dataRefactorMigrate.html'),
migrationV2: resolve(__dirname, 'src/renderer/migrationV2.html')
},
onwarn(warning, warn) {

View File

@ -140,7 +140,7 @@ export default defineConfig([
{
// Component Rules - prevent importing antd components when migration completed
files: ['**/*.{ts,tsx,js,jsx}'],
ignores: ['src/renderer/src/windows/dataRefactorTest/**/*.{ts,tsx}'],
ignores: [],
rules: {
// 'no-restricted-imports': [
// 'error',

View File

@ -26,20 +26,6 @@ export type UseCacheSchema = {
'topic.active': CacheValueTypes.CacheTopic | null
'topic.renaming': string[]
'topic.newly_renamed': string[]
// Test keys (for dataRefactorTest window)
// TODO: remove after testing
'test-hook-memory-1': string
'test-ttl-cache': string
'test-protected-cache': string
'test-deep-equal': { nested: { count: number }; tags: string[] }
'test-performance': number
'test-multi-hook': string
'concurrent-test-1': number
'concurrent-test-2': number
'large-data-test': Record<string, any>
'test-number-cache': number
'test-object-cache': { name: string; count: number; active: boolean }
}
export const DefaultUseCache: UseCacheSchema = {
@ -70,21 +56,7 @@ export const DefaultUseCache: UseCacheSchema = {
// Topic management
'topic.active': null,
'topic.renaming': [],
'topic.newly_renamed': [],
// Test keys (for dataRefactorTest window)
// TODO: remove after testing
'test-hook-memory-1': 'default-memory-value',
'test-ttl-cache': 'test-ttl-cache',
'test-protected-cache': 'protected-value',
'test-deep-equal': { nested: { count: 0 }, tags: ['initial'] },
'test-performance': 0,
'test-multi-hook': 'hook-1-default',
'concurrent-test-1': 0,
'concurrent-test-2': 0,
'large-data-test': {},
'test-number-cache': 42,
'test-object-cache': { name: 'test', count: 0, active: true }
'topic.newly_renamed': []
}
/**
@ -92,22 +64,10 @@ export const DefaultUseCache: UseCacheSchema = {
*/
export type UseSharedCacheSchema = {
'example-key': string
// Test keys (for dataRefactorTest window)
// TODO: remove after testing
'test-hook-shared-1': string
'test-multi-hook': string
'concurrent-shared': number
}
export const DefaultUseSharedCache: UseSharedCacheSchema = {
'example-key': 'example default value',
// Test keys (for dataRefactorTest window)
// TODO: remove after testing
'concurrent-shared': 0,
'test-hook-shared-1': 'default-shared-value',
'test-multi-hook': 'hook-3-shared'
'example-key': 'example default value'
}
/**
@ -116,24 +76,10 @@ export const DefaultUseSharedCache: UseSharedCacheSchema = {
*/
export type RendererPersistCacheSchema = {
'example-key': string
// Test keys (for dataRefactorTest window)
// TODO: remove after testing
'example-1': string
'example-2': string
'example-3': string
'example-4': string
}
export const DefaultRendererPersistCache: RendererPersistCacheSchema = {
'example-key': 'example default value',
// Test keys (for dataRefactorTest window)
// TODO: remove after testing
'example-1': 'example default value',
'example-2': 'example default value',
'example-3': 'example default value',
'example-4': 'example default value'
'example-key': 'example default value'
}
/**

View File

@ -30,7 +30,7 @@ src/main/data/
│ # - TopicRepository.ts # Complex: Topic data access
│ # - MessageRepository.ts # Complex: Message data access
├── db/ # Database layer
├── db/ # Database layer
│ ├── schemas/ # Drizzle table definitions
│ │ ├── preference.ts # Preference configuration table
│ │ ├── appState.ts # Application state table
@ -38,8 +38,8 @@ src/main/data/
│ ├── seeding/ # Database initialization
│ └── DbService.ts # Database connection and management
├── migrate/ # Data migration system
│ └── dataRefactor/ # v2 data refactoring migration tools
├── migration/ # Data migration system
│ └── v2/ # v2 data refactoring migration tools
├── CacheService.ts # Infrastructure: Cache management
├── DataApiService.ts # Infrastructure: API coordination

View File

@ -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()

View File

@ -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]
*/

View File

@ -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 }
}
}

View File

@ -195,17 +195,6 @@ if (!app.requestSingleInstanceLock()) {
// Initialize CacheService
await cacheService.initialize()
// // Create two test windows for cross-window preference sync testing
// logger.info('Creating test windows for PreferenceService cross-window sync testing')
// const testWindow1 = dataRefactorMigrateService.createTestWindow()
// const testWindow2 = dataRefactorMigrateService.createTestWindow()
// // Position windows to avoid overlap
// testWindow1.once('ready-to-show', () => {
// const [x, y] = testWindow1.getPosition()
// testWindow2.setPosition(x + 50, y + 50)
// })
/************FOR TESTING ONLY END****************/
// Record current version for tracking

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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 />)

View File

@ -1,272 +0,0 @@
# 数据重构项目测试窗口
专用于测试数据重构项目各项功能的独立测试窗口系统,包括 PreferenceService、CacheService、DataApiService 和相关 React hooks。
## 🎯 当前实现
**已完成的功能**
- 专用的测试窗口 (DataRefactorTestWindow)
- **双窗口启动**:应用启动时会同时打开主窗口和两个测试窗口
- **跨窗口同步测试**:两个测试窗口可以相互验证偏好设置的实时同步
- **实时UI联动**主题、语言、缩放等偏好设置变化会立即反映在UI上
- **🎛️ Slider联动测试**:多个交互式滑块控制数值类型偏好设置,支持跨窗口实时同步
- **多源窗口编号**支持URL参数、窗口标题、窗口名称等多种方式确定窗口编号
- 完整的测试界面包含4个专业测试组件
- 自动窗口定位,避免重叠
- 窗口编号标识,便于区分
## 测试组件
## PreferenceService 测试模块
### 1. PreferenceService 基础测试
- 直接测试服务层APIget, set, getCachedValue, isCached, preload, getMultiple
- 支持各种数据类型字符串、数字、布尔值、JSON对象
- 实时显示操作结果
### 2. usePreference Hook 测试
- 测试单个偏好设置的React hooks
- 支持的测试键:
- `app.theme.mode` - 主题模式
- `app.language` - 语言设置
- `app.spell_check.enabled` - 拼写检查
- `app.zoom_factor` - 缩放因子 (🎛️ 支持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机制和用户友好的错误提示
- **内存管理**: 自动清理、定时器管理和资源释放

View File

@ -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)' }}>
PreferenceServiceCacheServiceDataApiService
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/PersistTTL过期
</Text>
<Text style={{ color: 'var(--color-tertiary)', fontWeight: 'bold' }}>
🚀 API测试CRUDReact 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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' }}>
: &#123;"localKey": "newValue", ...&#125; - 使
</Text>
</Space>
</Card>
{/* Quick Actions */}
<Card size="small" title="快速操作">
<Space wrap>
<Button
onClick={() =>
updateValues(Object.fromEntries(Object.keys(currentKeys).map((key) => [key, 'test_value'])))
}>
</Button>
<Button
onClick={() => updateValues(Object.fromEntries(Object.keys(currentKeys).map((key) => [key, undefined])))}>
</Button>
<Button
onClick={async () => {
const toggles: Record<string, any> = {}
Object.entries(currentKeys).forEach(([localKey, prefKey]) => {
if (prefKey === '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

View File

@ -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

View File

@ -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 />)