diff --git a/__tests__/TextSyncPipeline.test.ts b/__tests__/TextSyncPipeline.test.ts new file mode 100644 index 0000000..180f243 --- /dev/null +++ b/__tests__/TextSyncPipeline.test.ts @@ -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); + }); + }); +}); diff --git a/__tests__/fakes/InMemoryFilePathStateHolder.ts b/__tests__/fakes/InMemoryFilePathStateHolder.ts new file mode 100644 index 0000000..9861498 --- /dev/null +++ b/__tests__/fakes/InMemoryFilePathStateHolder.ts @@ -0,0 +1,35 @@ +import { IFilePathStateHolder } from "src/IFilePathStateHolder"; + +export class InMemoryFilePathStateHolder implements IFilePathStateHolder { + private states: Map; + + constructor() { + this.states = new Map(); + } + + 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): void { + for (const path in initialStates) { + this.states.set(path, initialStates[path]); + } + } +} diff --git a/src/FileStateHolder.ts b/src/FileStateHolder.ts index 1ccd252..c2bc0ca 100644 --- a/src/FileStateHolder.ts +++ b/src/FileStateHolder.ts @@ -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; - private vault: Vault; - private mutex: Mutex; +export default class FileStateHolder implements IFilePathStateHolder{ + private map: Map; + 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 { - if (this.has(file)) { - return false; - } - let res = await this.mutex.runExclusive(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 { + if (this.has(file)) { + return false; + } + let res = await this.mutex.runExclusive(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; - } -} \ No newline at end of file + 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; + } +} diff --git a/src/IFilePathStateHolder.ts b/src/IFilePathStateHolder.ts new file mode 100644 index 0000000..8c5c925 --- /dev/null +++ b/src/IFilePathStateHolder.ts @@ -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; +} diff --git a/src/SyncController.ts b/src/SyncController.ts index cf68472..7ab4eff 100644 --- a/src/SyncController.ts +++ b/src/SyncController.ts @@ -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); + }); }); } diff --git a/src/TextSyncPipeline.ts b/src/TextSyncPipeline.ts new file mode 100644 index 0000000..d02fc31 --- /dev/null +++ b/src/TextSyncPipeline.ts @@ -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; + } +} diff --git a/src/main.ts b/src/main.ts index 6f45ea0..8d9c097 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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);