ref and add test

This commit is contained in:
Grol Grol 2025-05-21 15:28:04 +03:00
parent a851cc8428
commit 0b797bed5a
7 changed files with 413 additions and 101 deletions

View file

@ -0,0 +1,234 @@
import { CheckboxUtils } from "src/checkboxUtils";
import { FileFilter } from "src/FileFilter";
import TextSyncPipeline from "src/TextSyncPipeline";
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "src/types";
import { InMemoryFilePathStateHolder } from "./fakes/InMemoryFilePathStateHolder";
describe('TextSyncPipeline E2E-like Tests', () => {
let fakeFileStateHolder: InMemoryFilePathStateHolder;
let checkboxUtils: CheckboxUtils;
let fileFilter: FileFilter;
let pipeline: TextSyncPipeline;
let settings: CheckboxSyncPluginSettings;
beforeEach(() => {
// Используем настройки по умолчанию для большинства тестов,
// их можно переопределять для специфических сценариев
settings = { ...DEFAULT_SETTINGS };
fakeFileStateHolder = new InMemoryFilePathStateHolder();
checkboxUtils = new CheckboxUtils(settings);
fileFilter = new FileFilter(settings); // Инициализируем с настройками
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
});
// --- Тесты на разрешение путей и кеширование ---
describe('Path Filtering and Caching Logic', () => {
it('process: when path is NOT allowed, should return original text and cache original text', () => {
// Настраиваем FileFilter, чтобы он запрещал путь
settings.pathGlobs = ["restricted/path.md"]; // Этот путь будет игнорироваться
fileFilter.updateSettings(settings); // Обновляем фильтр с новыми глобами
const filePath = "restricted/path.md";
const currentText = "- [ ] task 1\n- [x] task 2";
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
// Assert
// 1. Текст не должен измениться
expect(resultText).toBe(currentText);
// 2. Оригинальный текст должен быть закеширован
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText);
});
it('process: when path IS allowed, but no sync changes occur, should return original text and cache original (synced) text', () => {
// Путь разрешен по умолчанию (pathGlobs пустой)
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
const filePath = "allowed/path.md";
// Текст, который не будет изменен CheckboxUtils при текущих настройках
// (например, нет родительских/дочерних для пропагации, или они уже синхронизованы)
const currentText = "- [ ] task 1\n- [ ] task 2";
const previousTextInHolder = "- [ ] task 1\n- [ ] task 2"; // Предположим, предыдущее состояние было таким же
fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder });
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
// Assert
// 1. Текст не должен измениться (т.к. syncText вернул то же самое)
expect(resultText).toBe(currentText);
// 2. В кеше должен быть этот же (оригинальный/синхронизированный) текст
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText);
});
it('process: when path IS allowed and sync changes occur, should return modified text and cache modified text', () => {
// Путь разрешен
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
// Включаем автоматическую пропагацию, чтобы изменения точно были
settings.enableAutomaticChildState = true;
checkboxUtils = new CheckboxUtils(settings); // Пересоздаем с новыми настройками
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "allowed/changes.md";
const currentText = "- [x] parent\n - [ ] child"; // Родитель изменен, дочерний должен измениться
const previousTextInHolder = "- [ ] parent\n - [ ] child"; // Предыдущее состояние
fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder });
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
const expectedTextAfterSync = "- [x] parent\n - [x] child"; // Ожидаемый результат от CheckboxUtils
// Assert
// 1. Текст должен измениться
expect(resultText).toBe(expectedTextAfterSync);
// 2. В кеше должен быть измененный текст
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedTextAfterSync);
});
it('process: when path IS allowed and file was not in state holder, should process and cache result', () => {
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
settings.enableAutomaticChildState = true;
checkboxUtils = new CheckboxUtils(settings);
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "allowed/new_file.md";
const currentText = "- [x] parent\n - [ ] child";
// Предыдущего состояния нет в fakeFileStateHolder
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
const expectedTextAfterSync = "- [ ] parent\n - [ ] child";
// Assert
expect(resultText).toBe(expectedTextAfterSync);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedTextAfterSync);
});
it('process: subsequent calls for the same allowed path should use updated cached state', () => {
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
settings.enableAutomaticChildState = true;
checkboxUtils = new CheckboxUtils(settings);
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "allowed/sequential.md";
const initialText = "- [ ] parent\n - [ ] child 1";
const textAfterFirstEdit = "- [x] parent\n - [ ] child 1"; // Пользователь изменил родителя
const expectedAfterFirstPipeline = "- [x] parent\n - [x] child 1";
// Первый вызов (например, после первого изменения в редакторе)
fakeFileStateHolder.setByPath(filePath, initialText);
let resultText = pipeline.applySyncLogic(textAfterFirstEdit, filePath); // previousText будет undefined
expect(resultText).toBe(expectedAfterFirstPipeline);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedAfterFirstPipeline);
// Второе изменение (пользователь снимает галочку с родителя)
const textAfterSecondEdit = "- [ ] parent\n - [x] child 1"; // previousText теперь expectedAfterFirstPipeline
const expectedAfterSecondPipeline = "- [ ] parent\n - [ ] child 1";
resultText = pipeline.applySyncLogic(textAfterSecondEdit, filePath);
expect(resultText).toBe(expectedAfterSecondPipeline);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedAfterSecondPipeline);
});
});
// --- Можно добавить тесты на специфическое поведение CheckboxUtils через pipeline ---
// Например, если enableAutomaticParentState = true и т.д.
describe('CheckboxUtils Behavior through Pipeline (Path Allowed)', () => {
beforeEach(() => {
// Убедимся, что путь всегда разрешен для этих тестов
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
});
it('process: should propagate state to children if enabled', () => {
settings.enableAutomaticChildState = true;
checkboxUtils = new CheckboxUtils(settings); // Обновляем CheckboxUtils
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "test.md";
const previousText = "- [ ] Parent\n - [ ] Child";
const currentText = "- [x] Parent\n - [ ] Child"; // Пользователь отметил родителя
const expectedText = "- [x] Parent\n - [x] Child";
fakeFileStateHolder.setInitialStates({ [filePath]: previousText });
const result = pipeline.applySyncLogic(currentText, filePath);
expect(result).toBe(expectedText);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedText);
});
it('process: should propagate state from children if enabled', () => {
settings.enableAutomaticParentState = true;
// Убедимся, что child state не влияет на этот тест, если он не нужен
settings.enableAutomaticChildState = false;
checkboxUtils = new CheckboxUtils(settings);
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "test.md";
// Предыдущее состояние не так важно, если мы не проверяем diff-логику
const currentText = "- [ ] Parent\n - [x] Child 1\n - [x] Child 2"; // Дети отмечены
const expectedText = "- [x] Parent\n - [x] Child 1\n - [x] Child 2"; // Родитель должен стать отмеченным
const result = pipeline.applySyncLogic(currentText, filePath);
expect(result).toBe(expectedText);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(expectedText);
});
});
describe('Text content variations (Path Allowed)', () => {
beforeEach(() => {
// Убедимся, что путь всегда разрешен для этих тестов
settings.pathGlobs = [];
fileFilter.updateSettings(settings);
// Оставляем настройки CheckboxUtils по умолчанию, если не указано иное
checkboxUtils = new CheckboxUtils(settings);
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
});
it('process: text with no checkboxes should return original text and cache it', () => {
const filePath = "text_files/no_checkboxes.md";
const currentText = "This is a line.\nAnd another line without any checkboxes.";
const previousTextInHolder = "Some old text";
fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder });
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
// Assert
expect(resultText).toBe(currentText);
// CheckboxUtils.syncText не должен был ничего изменить
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText);
});
it('process: text with only ignored checkboxes should return original text and cache it', () => {
settings.ignoreSymbols = ["~"];
checkboxUtils = new CheckboxUtils(settings); // Обновляем с новыми ignoreSymbols
pipeline = new TextSyncPipeline(checkboxUtils, fakeFileStateHolder, fileFilter);
const filePath = "text_files/ignored_checkboxes.md";
const currentText = "- [~] An ignored task\n - [~] Another sub-task also ignored";
const previousTextInHolder = "- [~] An old ignored task";
fakeFileStateHolder.setInitialStates({ [filePath]: previousTextInHolder });
// Act
const resultText = pipeline.applySyncLogic(currentText, filePath);
// Assert
expect(resultText).toBe(currentText);
expect(fakeFileStateHolder.getInternalState(filePath)).toBe(currentText);
});
});
});

View file

@ -0,0 +1,35 @@
import { IFilePathStateHolder } from "src/IFilePathStateHolder";
export class InMemoryFilePathStateHolder implements IFilePathStateHolder {
private states: Map<string, string>;
constructor() {
this.states = new Map<string, string>();
}
public setByPath(filePath: string, text: string): void {
console.log(`InMemoryFileStateHolder: Setting state for "${filePath}"`);
this.states.set(filePath, text);
}
public getByPath(filePath: string): string | undefined {
const state = this.states.get(filePath);
console.log(`InMemoryFileStateHolder: Getting state for "${filePath}". Found: ${!!state}`);
return state;
}
// Вспомогательные методы для тестов (не часть интерфейса, но полезны)
public clear(): void {
this.states.clear();
}
public getInternalState(filePath: string): string | undefined {
return this.states.get(filePath); // Для прямых проверок в тестах
}
public setInitialStates(initialStates: Record<string, string>): void {
for (const path in initialStates) {
this.states.set(path, initialStates[path]);
}
}
}

View file

@ -1,66 +1,84 @@
import { Mutex } from "async-mutex";
import { TFile, Vault } from "obsidian";
import { IFilePathStateHolder } from "./IFilePathStateHolder";
export default class FileStateHolder {
private map: Map<TFile, string>;
private vault: Vault;
private mutex: Mutex;
export default class FileStateHolder implements IFilePathStateHolder{
private map: Map<TFile, string>;
private vault: Vault;
private mutex: Mutex;
constructor(vault: Vault) {
this.vault = vault;
this.map = new Map();
this.mutex = new Mutex();
}
constructor(vault: Vault) {
this.vault = vault;
this.map = new Map();
this.mutex = new Mutex();
}
/**
* Initializes the file's data if it hasn't been initialized yet.
*
* @param file - The file object to initialize.
* @param text - Optional text content to associate with the file. If not provided, it will be read from the vault.
* @returns A promise that resolves to `true` if the file was initialized, or `false` if it was already initialized.
*/
async initIfNeeded(file: TFile, text?: string): Promise<boolean> {
if (this.has(file)) {
return false;
}
let res = await this.mutex.runExclusive<boolean>(async () => {
if (this.has(file)) {
return false;
}
// console.log(`updateIfNeeded "${file.name}" start`);
if (!text) {
text = await this.vault.read(file);
}
this.set(file, text);
return true;
});
return res;
}
/**
* Initializes the file's data if it hasn't been initialized yet.
*
* @param file - The file object to initialize.
* @param text - Optional text content to associate with the file. If not provided, it will be read from the vault.
* @returns A promise that resolves to `true` if the file was initialized, or `false` if it was already initialized.
*/
async initIfNeeded(file: TFile, text?: string): Promise<boolean> {
if (this.has(file)) {
return false;
}
let res = await this.mutex.runExclusive<boolean>(async () => {
if (this.has(file)) {
return false;
}
// console.log(`updateIfNeeded "${file.name}" start`);
if (!text) {
text = await this.vault.read(file);
}
this.set(file, text);
return true;
});
return res;
}
has(file: TFile) {
return this.map.has(file);
}
has(file: TFile) {
return this.map.has(file);
}
set(file: TFile, text: string) {
if (text !== this.map.get(file)) {
console.log(`File "${file.name}" load to holder`);
this.map.set(file, text);
}
}
set(file: TFile, text: string) {
if (text !== this.map.get(file)) {
console.log(`File "${file.name}" load to holder`);
this.map.set(file, text);
}
}
get(file: TFile) {
return this.map.get(file);
}
setByPath(filePath: string, text: string) {
const file = this.vault.getFileByPath(filePath);
if (file) {
this.set(file, text);
} else {
throw new Error(`file not found by path: ${filePath}.`);
}
}
getAllFiles(): TFile[] {
const keysIterator = this.map.keys();
const keysArray = Array.from(keysIterator);
return keysArray;
}
get(file: TFile) {
return this.map.get(file);
}
delete(file: TFile): boolean {
const existed = this.map.delete(file);
return existed;
}
}
getByPath(filePath: string) {
const file = this.vault.getFileByPath(filePath);
if (file) {
return this.get(file);
}
return undefined;
}
getAllFiles(): TFile[] {
const keysIterator = this.map.keys();
const keysArray = Array.from(keysIterator);
return keysArray;
}
delete(file: TFile): boolean {
const existed = this.map.delete(file);
return existed;
}
}

View file

@ -0,0 +1,16 @@
export interface IFilePathStateHolder {
/**
* Sets or updates the text state associated with a file identified by its path.
* @param filePath - The path of the file.
* @param text - The text content to store.
* @throws Error if the file is not found by path (implementations may vary).
*/
setByPath(filePath: string, text: string): void;
/**
* Retrieves the text state associated with a file identified by its path.
* @param filePath - The path of the file.
* @returns The stored text content, or `undefined` if not found or file not found.
*/
getByPath(filePath: string): string | undefined;
}

View file

@ -1,24 +1,19 @@
import { Mutex } from "async-mutex";
import { Editor, EditorChange, MarkdownFileInfo, MarkdownView, TFile, Vault } from "obsidian";
import { CheckboxUtils } from "./checkboxUtils";
import FileStateHolder from "./FileStateHolder";
import { FileFilter } from "./FileFilter";
import TextSyncPipeline from "./TextSyncPipeline";
export default class SyncController {
// private plugin: CheckboxSyncPlugin;//delete
private vault: Vault;
private checkboxUtils: CheckboxUtils;
private fileStateHolder: FileStateHolder;
private fileFilter: FileFilter;
textSyncPipeline: TextSyncPipeline
private mutex: Mutex;
constructor(vault: Vault, checkboxUtils: CheckboxUtils, fileStateHolder: FileStateHolder, fileFilter: FileFilter) {
// this.plugin = plugin;//delete
constructor(vault: Vault, checkboxUtils: CheckboxUtils, textSyncPipeline: TextSyncPipeline) {
this.vault = vault;
this.checkboxUtils = checkboxUtils;
this.fileStateHolder = fileStateHolder;
this.fileFilter = fileFilter;
this.textSyncPipeline = textSyncPipeline;
this.mutex = new Mutex();
}
@ -27,27 +22,10 @@ export default class SyncController {
return;
}
await this.mutex.runExclusive(async () => {
const file = info.file!;
console.log(`sync editor "${file.path}" start.`);
const text = editor.getValue();
if (!this.fileFilter.isPathAllowed(file.path)) {
console.log(`sync editor "${file.path}" skip, path is not allowed.`);
this.fileStateHolder.set(file, text);
return;
}
const textBefore = this.fileStateHolder.get(file);
let newText = this.checkboxUtils.syncText(text, textBefore);
this.fileStateHolder.set(file, newText);
if (newText === text) {
console.log(`sync editor "${file.basename}" stop. new text equals old text.`);
} else {
this.editEditor(editor, text, newText);
console.log(`syncEditor "${file.basename}" stop.`);
const currentText = editor.getValue();
const resultingText = this.textSyncPipeline.applySyncLogic(currentText, info.file!.path);
if (resultingText !== currentText) {
this.editEditor(editor, currentText, resultingText);
}
});
}
@ -57,21 +35,9 @@ export default class SyncController {
return;
}
await this.mutex.runExclusive(async () => {
console.log(`sync file "${file.path}" start.`);
if (!this.fileFilter.isPathAllowed(file.path)) {
console.log(`sync file "${file.path}" skip, path is not allowed.`);
const text = await this.vault.read(file);
this.fileStateHolder.set(file, text);
} else {
const newText = await this.vault.process(file, (text) => {
let textBefore = this.fileStateHolder.get(file);
let newText = this.checkboxUtils.syncText(text, textBefore);
return newText;
});
this.fileStateHolder.set(file, newText);
}
console.log(`sync file "${file.basename}" stop.`);
this.vault.process(file, (currentText) => {
return this.textSyncPipeline.applySyncLogic(currentText, file.path);
});
});
}

40
src/TextSyncPipeline.ts Normal file
View file

@ -0,0 +1,40 @@
import { CheckboxUtils } from "./checkboxUtils";
import { FileFilter } from "./FileFilter";
import { IFilePathStateHolder } from "./IFilePathStateHolder";
export default class TextSyncPipeline {
private checkboxUtils: CheckboxUtils;
private fileStateHolder: IFilePathStateHolder;
private fileFilter: FileFilter;
constructor(checkboxUtils: CheckboxUtils, fileStateHolder: IFilePathStateHolder, fileFilter: FileFilter) {
this.checkboxUtils = checkboxUtils;
this.fileStateHolder = fileStateHolder;
this.fileFilter = fileFilter;
}
public applySyncLogic(currentText: string, filePath: string): string {
return this.fileStateHolderDecorator(currentText, filePath);
}
private coreDecorator(currentText: string, textBefore: string | undefined): string {
const resultingText = this.checkboxUtils.syncText(currentText, textBefore);
return resultingText;
}
private pathAllowedDecorator(currentText: string, textBefore: string | undefined, filePath: string): string {
if (!this.fileFilter.isPathAllowed(filePath)) {
console.log(`pathAllowedDecorator "${filePath}" skip, path is not allowed.`);
return currentText;
}
return this.coreDecorator(currentText, textBefore);
}
private fileStateHolderDecorator(currentText: string, filePath: string): string {
const textBefore = this.fileStateHolder.getByPath(filePath);
const resultingText = this.pathAllowedDecorator(currentText, textBefore, filePath);
this.fileStateHolder.setByPath(filePath, resultingText);
return resultingText;
}
}

View file

@ -7,6 +7,7 @@ import { CheckboxSyncPluginSettingTab } from "./ui/CheckboxSyncPluginSettingTab"
import SyncController from "./SyncController";
import { CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from "./types";
import { FileFilter } from "./FileFilter";
import TextSyncPipeline from "./TextSyncPipeline";
const DEBUG_FLAG_NAME = 'CHECKBOX_SYNC_DEBUG';
@ -27,6 +28,7 @@ export default class CheckboxSyncPlugin extends Plugin {
private fileLoadEventHandler: FileLoadEventHandler;
private fileChangeEventHandler: FileChangeEventHandler;
private fileFilter: FileFilter;
private textSyncPipeline: TextSyncPipeline;
async onload() {
await this.loadSettings();
@ -34,7 +36,8 @@ export default class CheckboxSyncPlugin extends Plugin {
this.fileFilter = new FileFilter(this.settings);
this.fileStateHolder = new FileStateHolder(this.app.vault);
this.checkboxUtils = new CheckboxUtils(this.settings);
this.syncController = new SyncController(this.app.vault, this.checkboxUtils, this.fileStateHolder, this.fileFilter);
this.textSyncPipeline = new TextSyncPipeline(this.checkboxUtils,this.fileStateHolder, this.fileFilter);
this.syncController = new SyncController(this.app.vault, this.checkboxUtils, this.textSyncPipeline);
this.fileLoadEventHandler = new FileLoadEventHandler(this, this.app, this.syncController, this.fileStateHolder);
this.fileChangeEventHandler = new FileChangeEventHandler(this, this.app, this.syncController, this.fileStateHolder);