refactor(settings): Add EnableParentSync component and testable validator

This commit is contained in:
Grol Grol 2025-04-26 23:25:41 +03:00
parent 0f4b53f8dd
commit 840a50ae41
6 changed files with 212 additions and 4 deletions

View file

@ -0,0 +1,70 @@
import { validateValueIsBoolean } from "src/ui/validation/validators";
// Группа тестов для всего файла валидаторов
describe('Validation Utility Functions', () => {
// Подгруппа тестов для конкретной функции validateValueIsBoolean
describe('validateValueIsBoolean', () => {
// Тест кейсы для валидных значений (должны возвращать null)
test('should return null for true', () => {
expect(validateValueIsBoolean(true)).toBeNull();
});
test('should return null for false', () => {
expect(validateValueIsBoolean(false)).toBeNull();
});
// Тест кейсы для невалидных значений (должны возвращать объект ошибки)
test('should return error object for null', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean(null)).toEqual(expectedError);
});
test('should return error object for undefined', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean(undefined)).toEqual(expectedError);
});
test('should return error object for numbers', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean(0)).toEqual(expectedError);
expect(validateValueIsBoolean(1)).toEqual(expectedError);
expect(validateValueIsBoolean(123)).toEqual(expectedError);
expect(validateValueIsBoolean(-10)).toEqual(expectedError);
});
test('should return error object for strings', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean('')).toEqual(expectedError);
expect(validateValueIsBoolean('true')).toEqual(expectedError);
expect(validateValueIsBoolean('false')).toEqual(expectedError);
expect(validateValueIsBoolean('any string')).toEqual(expectedError);
});
test('should return error object for objects', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean({})).toEqual(expectedError);
expect(validateValueIsBoolean({ a: 1 })).toEqual(expectedError);
});
test('should return error object for arrays', () => {
const expectedError = { message: 'Value must be a boolean.' };
expect(validateValueIsBoolean([])).toEqual(expectedError);
expect(validateValueIsBoolean([true])).toEqual(expectedError);
});
});
// --- Сюда можно будет добавлять describe-блоки для других функций валидации ---
/*
describe('validateJsonStringArray', () => {
// ... тесты для валидации JSON ...
});
describe('validateNotEmptyArray', () => {
// ... тесты для проверки на пустой массив ...
});
*/
});

View file

@ -36,6 +36,12 @@ const config: Config = {
// Limit the number of workers that run tests in parallel
maxWorkers: 1, // <--- Задает последовательное выполнение
moduleNameMapper: {
// Этот паттерн говорит: если импорт начинается с 'src/',
// замени 'src/' на '<rootDir>/src/' при поиске файла.
'^src/(.*)$': '<rootDir>/src/$1',
},
};
export default config;

View file

@ -1,5 +1,5 @@
import { Setting } from 'obsidian';
import { CheckboxSyncPluginSettings } from '../../types';
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../../types';
import { ISettingComponent } from '../interfaces/ISettingComponent';
import { ValidationError } from '../validation/types';
// Убрали App и CheckboxSyncPlugin из импортов, если они не нужны ВСЕМ наследникам
@ -13,13 +13,26 @@ export abstract class BaseSettingComponent implements ISettingComponent {
// --- Методы для реализации наследниками ---
abstract getSettingKey(): keyof CheckboxSyncPluginSettings;
abstract getDefaultValue(): any;
// render теперь должен сам создавать Setting и сохранять ссылку в this.setting
abstract render(container: HTMLElement, currentValue: any): void;
abstract getValueFromUi(): any;
abstract setValueInUi(value: any): void;
abstract validate(value: any): ValidationError | null;
abstract validate(): ValidationError | null;
public getDefaultValue(): any {
const key = this.getSettingKey(); // Получаем ключ от наследника
if (key in DEFAULT_SETTINGS) {
// Возвращаем значение из импортированного объекта
return DEFAULT_SETTINGS[key];
} else {
// Логируем предупреждение, если ключ не найден (ошибка конфигурации)
console.warn(
`[${this.constructor.name}] Default value for key "${key}" not found in DEFAULT_SETTINGS. Returning undefined.`
);
return undefined; // Или null, или выбросить ошибку, но undefined безопаснее
}
}
// --- Общая реализация ---
public setChangeListener(listener: () => void): void {
this.onChangeCallback = listener;

View file

@ -0,0 +1,70 @@
import { Setting, ToggleComponent } from 'obsidian';
import { CheckboxSyncPluginSettings } from '../../types';
import { BaseSettingComponent } from './BaseSettingComponent';
import { ValidationError } from '../validation/types';
import { validateValueIsBoolean } from '../validation/validators';
export class EnableParentSyncSettingComponent extends BaseSettingComponent {
private toggleComponent: ToggleComponent;
constructor() {
super();
}
getSettingKey(): keyof CheckboxSyncPluginSettings {
return 'enableAutomaticParentState'; // Ключ этой настройки
}
render(container: HTMLElement, currentValue: any): void {
this.setting = new Setting(container)
.setName('Update parent checkbox state automatically')
.setDesc('If enabled, the state of a parent checkbox will be automatically updated based on the state of its children (all children checked = parent checked). If disabled, only manually changing a parent will affect its children.')
.addToggle(toggle => {
this.toggleComponent = toggle;
// Используем as boolean, т.к. знаем тип для этой настройки
toggle
.setValue(currentValue as boolean)
.onChange(this.onChangeCallback); // Просто передаем колбэк
});
}
getValueFromUi(): boolean {
if (this.toggleComponent) {
return this.toggleComponent.getValue();
}
console.warn(`getValueFromUi called before render for ${this.getSettingKey()}`);
// Используем унаследованный getDefaultValue() для консистентности
return this.getDefaultValue();
}
setValueInUi(value: any): void {
if (this.toggleComponent) {
this.toggleComponent.setValue(value as boolean);
}
}
validate(): ValidationError | null {
let valueFromUi: boolean;
try {
valueFromUi = this.getValueFromUi();
} catch (error) {
console.error(`Error getting value from UI for ${this.getSettingKey()}:`, error);
return {
field: this.getSettingKey(),
message: `Internal error getting value from UI: ${error instanceof Error ? error.message : String(error)}`
};
}
// Вызываем внешнюю чистую функцию валидации
const validationResult = validateValueIsBoolean(valueFromUi);
if (validationResult) {
return {
field: this.getSettingKey(),
message: validationResult.message
};
}
return null;
}
}

View file

@ -47,7 +47,7 @@ export interface ISettingComponent {
* @param value - Чистое значение для валидации (обычно результат getValueFromUi).
* @returns Объект ValidationError, если значение невалидно, иначе null.
*/
validate(value: any): ValidationError | null;
validate(): ValidationError | null;
/**
* Устанавливает колбэк, который будет вызван при изменении значения в UI.

View file

@ -0,0 +1,49 @@
// src/settings/validation/validators.ts
/**
* Проверяет, является ли предоставленное значение булевым типом (boolean).
* Эта функция не зависит от контекста Obsidian или конкретного компонента.
*
* @param value - Значение любого типа для проверки.
* @returns Объект с сообщением об ошибке, если значение не является boolean, иначе null.
*/
export function validateValueIsBoolean(value: any): { message: string } | null {
if (typeof value !== 'boolean') {
return { message: 'Value must be a boolean.' };
}
return null;
}
// --- Сюда в будущем можно будет добавлять другие чистые функции валидации ---
/*
// Пример для будущей валидации JSON-массива строк
export function validateJsonStringArray(rawJson: string): { message: string } | null {
try {
const parsed = JSON.parse(rawJson);
if (!Array.isArray(parsed)) {
return { message: 'Input must be a valid JSON array (e.g., ["x", " "]).' };
}
for (let i = 0; i < parsed.length; i++) {
const element = parsed[i];
if (typeof element !== 'string') {
return { message: `Array element at index ${i} is not a string.` };
}
if (element.length !== 1) {
return { message: `Element "${element}" at index ${i} must be a single character.` };
}
}
return null; // Все проверки пройдены
} catch (e: any) {
return { message: `Invalid JSON format: ${e.message}` };
}
}
// Пример для проверки, что массив не пустой
export function validateNotEmptyArray(arr: any[]): { message: string } | null {
if (!Array.isArray(arr) || arr.length === 0) {
return { message: 'The list cannot be empty.' };
}
return null;
}
*/