mirror of
https://github.com/jinmugo/obsidian-context-workspaces.git
synced 2026-07-22 16:20:27 +00:00
refactor: improve type safety and add unit tests
- Add Obsidian internal API type definitions to replace @ts-expect-error comments - Introduce asInternal() helper for typed access to undocumented APIs - Upgrade ESLint to recommendedTypeChecked rules - Add unit tests for performance-monitor and validation utilities - Fix async/await usage and type assertions - Bump version to 0.1.2
This commit is contained in:
parent
4feb6e86ae
commit
ee9e7a6684
11 changed files with 691 additions and 87 deletions
|
|
@ -11,7 +11,7 @@ export default tseslint.config(
|
|||
files: ['src/**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
|
|
@ -39,6 +39,7 @@ export default tseslint.config(
|
|||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/ban-ts-comment': 'warn',
|
||||
'@typescript-eslint/require-await': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "context-workspaces",
|
||||
"name": "Context Workspaces",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "A workspace toolkit to build focused, clutter-free zones for every task.",
|
||||
"author": "Jinmu Go",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-context-workspaces",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"description": "A workspace toolkit to build focused, clutter-free zones for every task.",
|
||||
"main": "main.js",
|
||||
"engines": {
|
||||
|
|
|
|||
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- lefthook
|
||||
22
src/main.ts
22
src/main.ts
|
|
@ -184,7 +184,7 @@ export default class ContextWorkspacesPlugin extends Plugin {
|
|||
}
|
||||
|
||||
if (leaf) {
|
||||
workspace.revealLeaf(leaf);
|
||||
await workspace.revealLeaf(leaf);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +200,11 @@ export default class ContextWorkspacesPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
this.settings = Object.assign(
|
||||
{},
|
||||
DEFAULT_SETTINGS,
|
||||
(await this.loadData()) as Partial<ContextWorkspacesSettings> | null,
|
||||
);
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
@ -795,14 +799,12 @@ export default class ContextWorkspacesPlugin extends Plugin {
|
|||
|
||||
// Set up periodic sync (every 30 seconds) only if sync is needed
|
||||
setInterval(() => {
|
||||
void (async () => {
|
||||
if (
|
||||
needsSync(this.app, this.settings) ||
|
||||
needsDeletionDetection(this.app, this.settings)
|
||||
) {
|
||||
this.handleWorkspaceChange();
|
||||
}
|
||||
})();
|
||||
if (
|
||||
needsSync(this.app, this.settings) ||
|
||||
needsDeletionDetection(this.app, this.settings)
|
||||
) {
|
||||
this.handleWorkspaceChange();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,56 @@
|
|||
// ===== Obsidian Internal API Type Definitions =====
|
||||
|
||||
/**
|
||||
* Obsidian's internal customCss interface (not officially documented)
|
||||
*/
|
||||
export interface ObsidianCustomCss {
|
||||
theme?: string;
|
||||
themes?: Record<string, unknown>;
|
||||
setTheme?: (themeName: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian's internal theme plugin instance interface
|
||||
*/
|
||||
export interface ObsidianThemePluginInstance {
|
||||
setTheme?: (themeName: string) => void;
|
||||
setThemeMode?: (mode: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obsidian's internal plugins interface
|
||||
*/
|
||||
export interface ObsidianInternalPlugins {
|
||||
plugins: {
|
||||
workspaces?: {
|
||||
enabled?: boolean;
|
||||
instance?: WorkspacesInstance;
|
||||
};
|
||||
theme?: {
|
||||
instance?: ObsidianThemePluginInstance;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extended App interface for accessing Obsidian internal APIs
|
||||
*/
|
||||
export interface ObsidianAppInternal {
|
||||
customCss?: ObsidianCustomCss;
|
||||
internalPlugins: ObsidianInternalPlugins;
|
||||
workspace: {
|
||||
trigger?: (event: string) => void;
|
||||
};
|
||||
vault: {
|
||||
config?: {
|
||||
theme?: string;
|
||||
themeMode?: string;
|
||||
themes?: Record<string, unknown>;
|
||||
};
|
||||
saveConfig?: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Common Type Definitions =====
|
||||
|
||||
// Theme mode type
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
import type { App } from 'obsidian';
|
||||
import type {
|
||||
ContextWorkspacesPluginLike,
|
||||
ObsidianAppInternal,
|
||||
ThemeMode,
|
||||
WorkspacesInstance,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Cast App to internal API type for accessing undocumented Obsidian APIs
|
||||
*/
|
||||
function asInternal(app: App): ObsidianAppInternal {
|
||||
return app as unknown as ObsidianAppInternal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Obsidian's internal workspaces plugin
|
||||
*/
|
||||
export function getWorkspacesPlugin(app: App) {
|
||||
// @ts-expect-error - Obsidian 내부 API 접근
|
||||
return app.internalPlugins.plugins.workspaces as {
|
||||
enabled?: boolean;
|
||||
instance?: WorkspacesInstance;
|
||||
};
|
||||
export function getWorkspacesPlugin(app: App): {
|
||||
enabled?: boolean;
|
||||
instance?: WorkspacesInstance;
|
||||
} | undefined {
|
||||
return asInternal(app).internalPlugins.plugins.workspaces;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -199,17 +206,16 @@ export function workspaceExistsInObsidian(app: App, workspaceId: string): boolea
|
|||
*/
|
||||
export function getAvailableThemes(app: App): string[] {
|
||||
try {
|
||||
// Try multiple methods to get available themes
|
||||
const internal = asInternal(app);
|
||||
|
||||
// Method 1: Direct customCss access
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
const customCss = app.customCss;
|
||||
const customCss = internal.customCss;
|
||||
if (customCss?.themes) {
|
||||
return Object.keys(customCss.themes).sort();
|
||||
}
|
||||
|
||||
// Method 2: Check vault config
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
const vaultConfig = app.vault.config;
|
||||
const vaultConfig = internal.vault.config;
|
||||
if (vaultConfig?.themes) {
|
||||
return Object.keys(vaultConfig.themes).sort();
|
||||
}
|
||||
|
|
@ -229,7 +235,6 @@ export function getAvailableThemes(app: App): string[] {
|
|||
return commonThemes;
|
||||
} catch (error) {
|
||||
console.error('Failed to get available themes:', error);
|
||||
// Return default themes as fallback
|
||||
return ['obsidian', 'dark', 'light'];
|
||||
}
|
||||
}
|
||||
|
|
@ -239,16 +244,16 @@ export function getAvailableThemes(app: App): string[] {
|
|||
*/
|
||||
export function getCurrentTheme(app: App): string {
|
||||
try {
|
||||
const internal = asInternal(app);
|
||||
|
||||
// Method 1: Direct customCss access
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
const customCss = app.customCss;
|
||||
const customCss = internal.customCss;
|
||||
if (customCss?.theme) {
|
||||
return customCss.theme;
|
||||
}
|
||||
|
||||
// Method 2: Check vault config
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
const vaultConfig = app.vault.config;
|
||||
const vaultConfig = internal.vault.config;
|
||||
if (vaultConfig?.theme) {
|
||||
return vaultConfig.theme;
|
||||
}
|
||||
|
|
@ -274,44 +279,43 @@ export function getCurrentTheme(app: App): string {
|
|||
*/
|
||||
export async function setTheme(app: App, themeName: string): Promise<void> {
|
||||
try {
|
||||
const internal = asInternal(app);
|
||||
|
||||
// Method 1: Use customCss.setTheme if available (safest method)
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
const customCss = app.customCss;
|
||||
if (customCss?.setTheme && typeof customCss.setTheme === 'function') {
|
||||
const customCss = internal.customCss;
|
||||
if (customCss?.setTheme) {
|
||||
customCss.setTheme(themeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 2: Use Obsidian's built-in theme switching if available
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
if (app.internalPlugins?.plugins?.theme?.instance?.setTheme) {
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
app.internalPlugins.plugins.theme.instance.setTheme(themeName);
|
||||
const themePlugin = internal.internalPlugins.plugins.theme;
|
||||
if (themePlugin?.instance?.setTheme) {
|
||||
themePlugin.instance.setTheme(themeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 3: Update vault config directly (with safety checks)
|
||||
const vaultConfig = (app.vault as { config?: { theme?: string } }).config;
|
||||
const vaultConfig = internal.vault.config;
|
||||
if (vaultConfig) {
|
||||
// Only change if the theme is actually different
|
||||
if (vaultConfig.theme !== themeName) {
|
||||
vaultConfig.theme = themeName;
|
||||
await (app.vault as unknown as { saveConfig(): Promise<void> }).saveConfig();
|
||||
if (internal.vault.saveConfig) {
|
||||
await internal.vault.saveConfig();
|
||||
}
|
||||
|
||||
// Trigger theme change event
|
||||
const workspace = app.workspace as { trigger?: (event: string) => void };
|
||||
const workspace = internal.workspace;
|
||||
if (workspace.trigger) {
|
||||
workspace.trigger('css-change');
|
||||
}
|
||||
|
||||
|
||||
// Force Obsidian to refresh the theme immediately
|
||||
setTimeout(() => {
|
||||
// Trigger additional events to ensure theme is applied
|
||||
const event = new CustomEvent('theme-change', { detail: { theme: themeName } });
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Force a re-render of the workspace
|
||||
|
||||
if (workspace.trigger) {
|
||||
workspace.trigger('resize');
|
||||
}
|
||||
|
|
@ -345,7 +349,7 @@ export function getCurrentThemeMode(app: App): ThemeMode {
|
|||
|
||||
// If no explicit classes, check if it's system mode
|
||||
// Check config first
|
||||
const configMode = (app.vault as { config?: { themeMode?: string } }).config?.themeMode;
|
||||
const configMode = asInternal(app).vault.config?.themeMode;
|
||||
if (configMode === 'light' || configMode === 'dark') {
|
||||
return configMode;
|
||||
}
|
||||
|
|
@ -386,11 +390,12 @@ export async function setThemeMode(app: App, mode: ThemeMode): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
const internal = asInternal(app);
|
||||
|
||||
// Use Obsidian's built-in theme mode switching if available
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
if (app.internalPlugins?.plugins?.theme?.instance?.setThemeMode) {
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
app.internalPlugins.plugins.theme.instance.setThemeMode(mode);
|
||||
const themePlugin = internal.internalPlugins.plugins.theme;
|
||||
if (themePlugin?.instance?.setThemeMode) {
|
||||
themePlugin.instance.setThemeMode(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -415,28 +420,28 @@ export async function setThemeMode(app: App, mode: ThemeMode): Promise<void> {
|
|||
}
|
||||
|
||||
// Update config for persistence
|
||||
const vaultConfig = (app.vault as { config?: { themeMode?: string } }).config;
|
||||
const vaultConfig = internal.vault.config;
|
||||
if (vaultConfig) {
|
||||
vaultConfig.themeMode = mode;
|
||||
await (app.vault as unknown as { saveConfig(): Promise<void> }).saveConfig();
|
||||
if (internal.vault.saveConfig) {
|
||||
await internal.vault.saveConfig();
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger theme change events
|
||||
const workspace = app.workspace as { trigger?: (event: string) => void };
|
||||
const workspace = internal.workspace;
|
||||
if (workspace.trigger) {
|
||||
workspace.trigger('css-change');
|
||||
}
|
||||
|
||||
// Dispatch custom event for theme mode change
|
||||
window.dispatchEvent(new CustomEvent('theme-mode-change', { detail: { mode } }));
|
||||
|
||||
|
||||
// Force Obsidian to refresh the theme mode immediately
|
||||
setTimeout(() => {
|
||||
// Trigger additional events to ensure theme mode is applied
|
||||
const event = new CustomEvent('theme-change', { detail: { mode } });
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Force a re-render of the workspace
|
||||
|
||||
if (workspace.trigger) {
|
||||
workspace.trigger('resize');
|
||||
}
|
||||
|
|
@ -498,39 +503,37 @@ export function applySpaceTheme(
|
|||
*/
|
||||
function setThemeTemporarily(app: App, themeName: string): void {
|
||||
try {
|
||||
const internal = asInternal(app);
|
||||
|
||||
// Method 1: Use customCss.setTheme if available (safest method)
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
const customCss = app.customCss;
|
||||
if (customCss?.setTheme && typeof customCss.setTheme === 'function') {
|
||||
const customCss = internal.customCss;
|
||||
if (customCss?.setTheme) {
|
||||
customCss.setTheme(themeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 2: Use Obsidian's built-in theme switching if available
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
if (app.internalPlugins?.plugins?.theme?.instance?.setTheme) {
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
app.internalPlugins.plugins.theme.instance.setTheme(themeName);
|
||||
const themePlugin = internal.internalPlugins.plugins.theme;
|
||||
if (themePlugin?.instance?.setTheme) {
|
||||
themePlugin.instance.setTheme(themeName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Method 3: Apply theme without saving to config (temporary change)
|
||||
if (customCss?.theme) {
|
||||
if (customCss) {
|
||||
customCss.theme = themeName;
|
||||
|
||||
|
||||
// Trigger theme change event without saving config
|
||||
const workspace = app.workspace as { trigger?: (event: string) => void };
|
||||
const workspace = internal.workspace;
|
||||
if (workspace.trigger) {
|
||||
workspace.trigger('css-change');
|
||||
}
|
||||
|
||||
// Force Obsidian to refresh the theme immediately
|
||||
setTimeout(() => {
|
||||
// Trigger additional events to ensure theme is applied
|
||||
const event = new CustomEvent('theme-change', { detail: { theme: themeName } });
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Force a re-render of the workspace
|
||||
|
||||
if (workspace.trigger) {
|
||||
workspace.trigger('resize');
|
||||
}
|
||||
|
|
@ -538,7 +541,7 @@ function setThemeTemporarily(app: App, themeName: string): void {
|
|||
return;
|
||||
}
|
||||
|
||||
// Method 4: Fallback - try to reload the page theme
|
||||
// Method 4: Fallback
|
||||
console.warn('Using fallback temporary theme setting method');
|
||||
throw new Error('Unable to set theme temporarily - no supported method available');
|
||||
} catch (error) {
|
||||
|
|
@ -560,11 +563,12 @@ function setThemeModeTemporarily(app: App, mode: ThemeMode): void {
|
|||
return;
|
||||
}
|
||||
|
||||
const internal = asInternal(app);
|
||||
|
||||
// Use Obsidian's built-in theme mode switching if available
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
if (app.internalPlugins?.plugins?.theme?.instance?.setThemeMode) {
|
||||
// @ts-expect-error - Obsidian internal API access
|
||||
app.internalPlugins.plugins.theme.instance.setThemeMode(mode);
|
||||
const themePlugin = internal.internalPlugins.plugins.theme;
|
||||
if (themePlugin?.instance?.setThemeMode) {
|
||||
themePlugin.instance.setThemeMode(mode);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -576,10 +580,8 @@ function setThemeModeTemporarily(app: App, mode: ThemeMode): void {
|
|||
body.classList.remove('theme-dark');
|
||||
body.classList.add('theme-light');
|
||||
} else if (mode === 'system') {
|
||||
// system mode - Remove both classes and apply system preference
|
||||
body.classList.remove('theme-dark', 'theme-light');
|
||||
|
||||
// Apply system preference
|
||||
const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
if (isDarkMode) {
|
||||
body.classList.add('theme-dark');
|
||||
|
|
@ -589,21 +591,17 @@ function setThemeModeTemporarily(app: App, mode: ThemeMode): void {
|
|||
}
|
||||
|
||||
// Trigger theme change events without saving config
|
||||
const workspace = app.workspace as { trigger?: (event: string) => void };
|
||||
const workspace = internal.workspace;
|
||||
if (workspace.trigger) {
|
||||
workspace.trigger('css-change');
|
||||
}
|
||||
|
||||
// Dispatch custom event for theme mode change
|
||||
window.dispatchEvent(new CustomEvent('theme-mode-change', { detail: { mode } }));
|
||||
|
||||
// Force Obsidian to refresh the theme mode immediately
|
||||
|
||||
setTimeout(() => {
|
||||
// Trigger additional events to ensure theme mode is applied
|
||||
const event = new CustomEvent('theme-change', { detail: { mode } });
|
||||
document.dispatchEvent(event);
|
||||
|
||||
// Force a re-render of the workspace
|
||||
|
||||
if (workspace.trigger) {
|
||||
workspace.trigger('resize');
|
||||
}
|
||||
|
|
@ -634,7 +632,9 @@ export function setupWorkspaceLoadMonitoring(app: App, plugin: ContextWorkspaces
|
|||
}
|
||||
|
||||
// Store original loadWorkspace method
|
||||
const originalLoadWorkspace = workspaces.instance.loadWorkspace.bind(workspaces.instance);
|
||||
const originalLoadWorkspace = workspaces.instance.loadWorkspace.bind(
|
||||
workspaces.instance,
|
||||
) as (id: string) => Promise<void>;
|
||||
workspaces.instance._originalLoadWorkspace = originalLoadWorkspace;
|
||||
|
||||
// Override loadWorkspace method to detect calls
|
||||
|
|
|
|||
|
|
@ -189,8 +189,8 @@ export function measurePerformance(name: string) {
|
|||
throw new Error('Original method is undefined');
|
||||
}
|
||||
return performanceMonitor.measure(`${name}.${propertyKey}`, () =>
|
||||
originalMethod.apply(this, args)
|
||||
);
|
||||
originalMethod.apply(this, args) as ReturnType<T>
|
||||
) as ReturnType<T>;
|
||||
} as T;
|
||||
return descriptor;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -39,13 +39,19 @@ export function parseSpaceData(input: string): {
|
|||
themeMode?: ThemeMode;
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(input);
|
||||
const parsed = JSON.parse(input) as {
|
||||
name: string;
|
||||
icon: string;
|
||||
description?: string;
|
||||
theme?: string;
|
||||
themeMode?: ThemeMode;
|
||||
};
|
||||
return {
|
||||
name: parsed.name,
|
||||
icon: parsed.icon,
|
||||
description: parsed.description,
|
||||
theme: (parsed.theme ?? '').trim() || undefined,
|
||||
themeMode: parsed.themeMode as ThemeMode | undefined,
|
||||
themeMode: parsed.themeMode,
|
||||
};
|
||||
} catch {
|
||||
// Legacy format compatibility
|
||||
|
|
|
|||
288
tests/performance-monitor.test.ts
Normal file
288
tests/performance-monitor.test.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
import {
|
||||
PerformanceMonitor,
|
||||
AsyncQueue,
|
||||
debounce,
|
||||
throttle,
|
||||
} from '../src/utils/performance-monitor';
|
||||
|
||||
describe('PerformanceMonitor', () => {
|
||||
let monitor: PerformanceMonitor;
|
||||
|
||||
beforeEach(() => {
|
||||
monitor = new PerformanceMonitor();
|
||||
});
|
||||
|
||||
describe('measure', () => {
|
||||
test('should record synchronous function execution', () => {
|
||||
const result = monitor.measure('sync-test', () => 42);
|
||||
expect(result).toBe(42);
|
||||
|
||||
const stats = monitor.getStatistics();
|
||||
expect(stats.count).toBe(1);
|
||||
expect(stats.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
test('should record async function execution', async () => {
|
||||
const result = await monitor.measure('async-test', async () => {
|
||||
return 'done';
|
||||
});
|
||||
expect(result).toBe('done');
|
||||
|
||||
const stats = monitor.getStatistics();
|
||||
expect(stats.count).toBe(1);
|
||||
expect(stats.errorCount).toBe(0);
|
||||
});
|
||||
|
||||
test('should record errors for failed sync functions', () => {
|
||||
expect(() => {
|
||||
monitor.measure('fail-sync', () => {
|
||||
throw new Error('sync error');
|
||||
});
|
||||
}).toThrow('sync error');
|
||||
|
||||
const stats = monitor.getStatistics();
|
||||
expect(stats.count).toBe(1);
|
||||
expect(stats.errorCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should record errors for failed async functions', async () => {
|
||||
await expect(
|
||||
monitor.measure('fail-async', async () => {
|
||||
throw new Error('async error');
|
||||
})
|
||||
).rejects.toThrow('async error');
|
||||
|
||||
const stats = monitor.getStatistics();
|
||||
expect(stats.count).toBe(1);
|
||||
expect(stats.errorCount).toBe(1);
|
||||
});
|
||||
|
||||
test('should wrap non-Error throws in Error objects', async () => {
|
||||
await expect(
|
||||
monitor.measure('fail-string', async () => {
|
||||
throw 'string error';
|
||||
})
|
||||
).rejects.toThrow('string error');
|
||||
|
||||
const failed = monitor.getFailedOperations();
|
||||
expect(failed).toHaveLength(1);
|
||||
expect(failed[0].error).toBe('string error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatistics', () => {
|
||||
test('should return zero stats when empty', () => {
|
||||
const stats = monitor.getStatistics();
|
||||
expect(stats.count).toBe(0);
|
||||
expect(stats.averageDuration).toBe(0);
|
||||
expect(stats.minDuration).toBe(0);
|
||||
expect(stats.maxDuration).toBe(0);
|
||||
});
|
||||
|
||||
test('should compute statistics across multiple measurements', () => {
|
||||
monitor.measure('op1', () => {});
|
||||
monitor.measure('op2', () => {});
|
||||
monitor.measure('op3', () => {});
|
||||
|
||||
const stats = monitor.getStatistics();
|
||||
expect(stats.count).toBe(3);
|
||||
expect(stats.errorCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResultsByName', () => {
|
||||
test('should filter results by name', () => {
|
||||
monitor.measure('alpha', () => {});
|
||||
monitor.measure('beta', () => {});
|
||||
monitor.measure('alpha', () => {});
|
||||
|
||||
expect(monitor.getResultsByName('alpha')).toHaveLength(2);
|
||||
expect(monitor.getResultsByName('beta')).toHaveLength(1);
|
||||
expect(monitor.getResultsByName('gamma')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
test('should reset all results', () => {
|
||||
monitor.measure('op', () => {});
|
||||
expect(monitor.getStatistics().count).toBe(1);
|
||||
|
||||
monitor.clear();
|
||||
expect(monitor.getStatistics().count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSlowThreshold', () => {
|
||||
test('should detect slow operations with custom threshold', () => {
|
||||
monitor.setSlowThreshold(0); // everything is "slow"
|
||||
monitor.measure('op', () => {});
|
||||
|
||||
expect(monitor.getSlowOperations()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AsyncQueue', () => {
|
||||
test('should execute tasks sequentially by default', async () => {
|
||||
const queue = new AsyncQueue(1);
|
||||
const order: number[] = [];
|
||||
|
||||
await Promise.all([
|
||||
queue.add(async () => {
|
||||
order.push(1);
|
||||
}),
|
||||
queue.add(async () => {
|
||||
order.push(2);
|
||||
}),
|
||||
queue.add(async () => {
|
||||
order.push(3);
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test('should handle task errors without breaking the queue', async () => {
|
||||
const queue = new AsyncQueue(1);
|
||||
|
||||
await expect(
|
||||
queue.add(async () => {
|
||||
throw new Error('task failed');
|
||||
})
|
||||
).rejects.toThrow('task failed');
|
||||
|
||||
// Queue should still work after error
|
||||
const result: string[] = [];
|
||||
await queue.add(async () => {
|
||||
result.push('recovered');
|
||||
});
|
||||
expect(result).toEqual(['recovered']);
|
||||
});
|
||||
|
||||
test('should wrap non-Error rejections in Error objects', async () => {
|
||||
const queue = new AsyncQueue(1);
|
||||
|
||||
await expect(
|
||||
queue.add(async () => {
|
||||
throw 'string rejection';
|
||||
})
|
||||
).rejects.toThrow('string rejection');
|
||||
});
|
||||
|
||||
test('should report queue length and running count', async () => {
|
||||
const queue = new AsyncQueue(1);
|
||||
expect(queue.getLength()).toBe(0);
|
||||
expect(queue.getRunningCount()).toBe(0);
|
||||
});
|
||||
|
||||
test('should clear the queue', () => {
|
||||
const queue = new AsyncQueue(1);
|
||||
queue.clear();
|
||||
expect(queue.getLength()).toBe(0);
|
||||
});
|
||||
|
||||
test('should respect maxConcurrent', async () => {
|
||||
const queue = new AsyncQueue(2);
|
||||
let maxConcurrent = 0;
|
||||
let currentRunning = 0;
|
||||
|
||||
const createTask = () => async () => {
|
||||
currentRunning++;
|
||||
maxConcurrent = Math.max(maxConcurrent, currentRunning);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
currentRunning--;
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
queue.add(createTask()),
|
||||
queue.add(createTask()),
|
||||
queue.add(createTask()),
|
||||
queue.add(createTask()),
|
||||
]);
|
||||
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('debounce', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should delay function execution', () => {
|
||||
const fn = jest.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(100);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should reset timer on subsequent calls', () => {
|
||||
const fn = jest.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced();
|
||||
jest.advanceTimersByTime(50);
|
||||
debounced(); // reset
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
jest.advanceTimersByTime(50);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should pass arguments to the original function', () => {
|
||||
const fn = jest.fn();
|
||||
const debounced = debounce(fn, 100);
|
||||
|
||||
debounced('arg1', 'arg2');
|
||||
jest.advanceTimersByTime(100);
|
||||
|
||||
expect(fn).toHaveBeenCalledWith('arg1', 'arg2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('throttle', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
test('should execute immediately on first call', () => {
|
||||
const fn = jest.fn();
|
||||
const throttled = throttle(fn, 100);
|
||||
|
||||
throttled();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should block subsequent calls within limit', () => {
|
||||
const fn = jest.fn();
|
||||
const throttled = throttle(fn, 100);
|
||||
|
||||
throttled();
|
||||
throttled();
|
||||
throttled();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should allow calls after limit expires', () => {
|
||||
const fn = jest.fn();
|
||||
const throttled = throttle(fn, 100);
|
||||
|
||||
throttled();
|
||||
jest.advanceTimersByTime(100);
|
||||
throttled();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
251
tests/validation.test.ts
Normal file
251
tests/validation.test.ts
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import {
|
||||
validateString,
|
||||
validateSpaceConfig,
|
||||
validateSpaceId,
|
||||
validateSettings,
|
||||
sanitizeString,
|
||||
sanitizeSpaceName,
|
||||
safeJsonParse,
|
||||
safeJsonStringify,
|
||||
validateThemeMode,
|
||||
validateThemeName,
|
||||
VALIDATION_RULES,
|
||||
} from '../src/utils/validation';
|
||||
|
||||
describe('validateString', () => {
|
||||
test('should pass with valid string and no rules', () => {
|
||||
const result = validateString('hello', {});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should fail when required and empty', () => {
|
||||
const result = validateString('', { required: true });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Value is required');
|
||||
});
|
||||
|
||||
test('should fail when required and undefined', () => {
|
||||
const result = validateString(undefined, { required: true });
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
test('should pass when not required and undefined', () => {
|
||||
const result = validateString(undefined, {});
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('should fail when value is not a string', () => {
|
||||
const result = validateString(123, {});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors).toContain('Value must be a string');
|
||||
});
|
||||
|
||||
test('should enforce minLength', () => {
|
||||
const result = validateString('ab', { minLength: 3 });
|
||||
expect(result.isValid).toBe(false);
|
||||
|
||||
const result2 = validateString('abc', { minLength: 3 });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('should enforce maxLength', () => {
|
||||
const result = validateString('abcdef', { maxLength: 5 });
|
||||
expect(result.isValid).toBe(false);
|
||||
|
||||
const result2 = validateString('abcde', { maxLength: 5 });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('should enforce pattern', () => {
|
||||
const result = validateString('ABC', { pattern: /^[a-z]+$/ });
|
||||
expect(result.isValid).toBe(false);
|
||||
|
||||
const result2 = validateString('abc', { pattern: /^[a-z]+$/ });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
test('should combine multiple validation errors', () => {
|
||||
const result = validateString('x', { minLength: 3, pattern: /^[0-9]+$/ });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSpaceConfig', () => {
|
||||
test('should validate a correct config', () => {
|
||||
expect(
|
||||
validateSpaceConfig({
|
||||
name: 'Test',
|
||||
icon: '🚀',
|
||||
autoSave: true,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should validate config with optional fields', () => {
|
||||
expect(
|
||||
validateSpaceConfig({
|
||||
name: 'Test',
|
||||
icon: '🚀',
|
||||
autoSave: false,
|
||||
theme: 'Minimal',
|
||||
themeMode: 'dark',
|
||||
description: 'A test space',
|
||||
createdAt: 1234567890,
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject null/undefined/non-object', () => {
|
||||
expect(validateSpaceConfig(null)).toBe(false);
|
||||
expect(validateSpaceConfig(undefined)).toBe(false);
|
||||
expect(validateSpaceConfig('string')).toBe(false);
|
||||
});
|
||||
|
||||
test('should reject missing required fields', () => {
|
||||
expect(validateSpaceConfig({})).toBe(false);
|
||||
expect(validateSpaceConfig({ name: 'Test' })).toBe(false);
|
||||
expect(validateSpaceConfig({ name: 'Test', icon: '🚀' })).toBe(false);
|
||||
});
|
||||
|
||||
test('should reject invalid optional field types', () => {
|
||||
const base = { name: 'Test', icon: '🚀', autoSave: true };
|
||||
expect(validateSpaceConfig({ ...base, theme: 123 })).toBe(false);
|
||||
expect(validateSpaceConfig({ ...base, themeMode: 'invalid' })).toBe(false);
|
||||
expect(validateSpaceConfig({ ...base, description: 123 })).toBe(false);
|
||||
expect(validateSpaceConfig({ ...base, createdAt: 'not-a-number' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSpaceId', () => {
|
||||
test('should accept valid space IDs', () => {
|
||||
expect(validateSpaceId('my-space')).toBe(true);
|
||||
expect(validateSpaceId('space123')).toBe(true);
|
||||
expect(validateSpaceId('a')).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject invalid space IDs', () => {
|
||||
expect(validateSpaceId('')).toBe(false);
|
||||
expect(validateSpaceId(123)).toBe(false);
|
||||
expect(validateSpaceId(null)).toBe(false);
|
||||
expect(validateSpaceId('My Space')).toBe(false); // uppercase and space
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSettings', () => {
|
||||
test('should validate correct settings', () => {
|
||||
expect(
|
||||
validateSettings({
|
||||
spaces: {
|
||||
'my-space': { name: 'My Space', icon: '🚀', autoSave: true },
|
||||
},
|
||||
spaceOrder: ['my-space'],
|
||||
currentSpaceId: 'my-space',
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('should reject null/non-object', () => {
|
||||
expect(validateSettings(null)).toBe(false);
|
||||
expect(validateSettings('string')).toBe(false);
|
||||
});
|
||||
|
||||
test('should reject missing required fields', () => {
|
||||
expect(validateSettings({})).toBe(false);
|
||||
expect(validateSettings({ spaces: {} })).toBe(false);
|
||||
expect(validateSettings({ spaces: {}, spaceOrder: [] })).toBe(false);
|
||||
});
|
||||
|
||||
test('should reject invalid space configs within settings', () => {
|
||||
expect(
|
||||
validateSettings({
|
||||
spaces: {
|
||||
'my-space': { name: 'Test' }, // missing icon and autoSave
|
||||
},
|
||||
spaceOrder: ['my-space'],
|
||||
currentSpaceId: 'my-space',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeString', () => {
|
||||
test('should trim whitespace', () => {
|
||||
expect(sanitizeString(' hello ')).toBe('hello');
|
||||
});
|
||||
|
||||
test('should collapse multiple spaces', () => {
|
||||
expect(sanitizeString('hello world')).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeSpaceName', () => {
|
||||
test('should remove special characters', () => {
|
||||
expect(sanitizeSpaceName('Hello@World!')).toBe('HelloWorld');
|
||||
});
|
||||
|
||||
test('should keep hyphens and alphanumeric', () => {
|
||||
expect(sanitizeSpaceName('my-space-123')).toBe('my-space-123');
|
||||
});
|
||||
|
||||
test('should trim and collapse spaces', () => {
|
||||
expect(sanitizeSpaceName(' hello world ')).toBe('hello world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJsonParse', () => {
|
||||
test('should parse valid JSON', () => {
|
||||
expect(safeJsonParse('{"a":1}', {})).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test('should return fallback for invalid JSON', () => {
|
||||
expect(safeJsonParse('not json', { fallback: true })).toEqual({ fallback: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeJsonStringify', () => {
|
||||
test('should stringify valid objects', () => {
|
||||
expect(safeJsonStringify({ a: 1 })).toBe('{"a":1}');
|
||||
});
|
||||
|
||||
test('should return fallback for circular references', () => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
obj.self = obj;
|
||||
expect(safeJsonStringify(obj, 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateThemeMode', () => {
|
||||
test('should accept valid theme modes', () => {
|
||||
expect(validateThemeMode('light')).toBe('light');
|
||||
expect(validateThemeMode('dark')).toBe('dark');
|
||||
expect(validateThemeMode('system')).toBe('system');
|
||||
});
|
||||
|
||||
test('should default to system for invalid values', () => {
|
||||
expect(validateThemeMode('invalid')).toBe('system');
|
||||
expect(validateThemeMode(123)).toBe('system');
|
||||
expect(validateThemeMode(null)).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateThemeName', () => {
|
||||
test('should accept valid theme names', () => {
|
||||
expect(validateThemeName('Minimal')).toBe('Minimal');
|
||||
expect(validateThemeName('My Theme-1')).toBe('My Theme-1');
|
||||
});
|
||||
|
||||
test('should reject invalid theme names', () => {
|
||||
expect(validateThemeName(123)).toBe('');
|
||||
expect(validateThemeName('invalid@name!')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('VALIDATION_RULES', () => {
|
||||
test('should have expected rule definitions', () => {
|
||||
expect(VALIDATION_RULES.SPACE_NAME.minLength).toBe(1);
|
||||
expect(VALIDATION_RULES.SPACE_NAME.maxLength).toBe(100);
|
||||
expect(VALIDATION_RULES.SPACE_ID.pattern).toBeInstanceOf(RegExp);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue