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:
JinmuGo 2026-04-03 16:25:08 +09:00
parent 4feb6e86ae
commit ee9e7a6684
11 changed files with 691 additions and 87 deletions

View file

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

View file

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

View file

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

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- esbuild
- lefthook

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
View 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);
});
});