implement feature

This commit is contained in:
Grol Grol 2025-05-20 02:33:50 +03:00
parent b18acc10b1
commit 5115988918
6 changed files with 1531 additions and 954 deletions

View file

@ -0,0 +1,932 @@
import { CheckboxLineInfo, CheckboxUtils } from '../src/checkboxUtils'; // Путь к вашему файлу
import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; // Путь к вашему файлу типов
// Вспомогательная функция для создания настроек с переопределениями
const createSettings = (overrides: Partial<CheckboxSyncPluginSettings> = {}): Readonly<CheckboxSyncPluginSettings> => {
return { ...DEFAULT_SETTINGS, ...overrides } as Readonly<CheckboxSyncPluginSettings>;
};
describe('CheckboxUtils', () => {
let checkboxUtils: CheckboxUtils;
let settings: Readonly<CheckboxSyncPluginSettings>;
beforeEach(() => {
// Используем настройки по умолчанию для большинства тестов
settings = createSettings();
checkboxUtils = new CheckboxUtils(settings);
});
// --- Тесты для matchCheckboxLine ---
describe('matchCheckboxLine', () => {
it('should match standard unchecked checkbox', () => {
const line = '- [ ] Task';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '-',
checkChar: ' ',
checkboxCharPosition: 3,
checkboxState: CheckboxState.Unchecked,
isChecked: false,
listItemText: "Task",
});
});
it('should match standard checked checkbox', () => {
const line = '* [x] Done';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '*',
checkChar: 'x',
checkboxCharPosition: 3,
checkboxState: CheckboxState.Checked,
isChecked: true,
listItemText: "Done",
});
});
it('should match checkbox with indentation', () => {
const line = ' + [x] Indented Task';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 2,
marker: '+',
checkChar: 'x',
checkboxCharPosition: 5, // 2 spaces + 1 marker + 1 space + 1 [ = 5
checkboxState: CheckboxState.Checked,
isChecked: true,
listItemText: "Indented Task",
});
});
it('should match numbered list checkbox', () => {
const line = '1. [ ] Numbered';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '1.',
checkChar: ' ',
checkboxCharPosition: 4, // 2 marker + 1 space + 1 [ = 4
checkboxState: CheckboxState.Unchecked,
isChecked: false,
listItemText: "Numbered",
});
});
it('should match checkbox with multi-digit numbered list', () => {
const line = '10. [x] Double Digit';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '10.',
checkChar: 'x',
checkboxCharPosition: 5, // 3 marker + 1 space + 1 [ = 5
checkboxState: CheckboxState.Checked,
isChecked: true,
listItemText: "Double Digit",
});
});
it('should return null for lines without checkbox format', () => {
expect(checkboxUtils.matchCheckboxLine('Just text')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('- [ ]')).toBeNull(); // No space after ]
expect(checkboxUtils.matchCheckboxLine('- [] ')).toBeNull(); // No char inside
expect(checkboxUtils.matchCheckboxLine('[ ] Task')).toBeNull(); // No marker
expect(checkboxUtils.matchCheckboxLine('-[ ] Task')).toBeNull(); // No space after marker
});
it('should match custom symbols based on settings', () => {
const customSettings = createSettings({
checkedSymbols: ['X', 'V'],
uncheckedSymbols: ['O', '-'],
ignoreSymbols: ['~'],
});
const customUtils = new CheckboxUtils(customSettings);
const checkedLine = '- [V] Custom Checked';
const checkedResult = customUtils.matchCheckboxLine(checkedLine);
expect(checkedResult?.checkboxState).toBe(CheckboxState.Checked);
expect(checkedResult?.isChecked).toBe(true);
expect(checkedResult?.checkChar).toBe('V');
const uncheckedLine = '* [O] Custom Unchecked';
const uncheckedResult = customUtils.matchCheckboxLine(uncheckedLine);
expect(uncheckedResult?.checkboxState).toBe(CheckboxState.Unchecked);
expect(uncheckedResult?.isChecked).toBe(false);
expect(uncheckedResult?.checkChar).toBe('O');
const ignoredLine = '+ [~] Custom Ignored';
const ignoredResult = customUtils.matchCheckboxLine(ignoredLine);
expect(ignoredResult?.checkboxState).toBe(CheckboxState.Ignore);
expect(ignoredResult?.isChecked).toBeUndefined();
expect(ignoredResult?.checkChar).toBe('~');
});
});
// --- Тесты для getCheckboxState ---
describe('getCheckboxState', () => {
it('should return Checked for symbols in checkedSymbols', () => {
const customSettings = createSettings({ checkedSymbols: ['x', 'X', '✓'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState('x')).toBe(CheckboxState.Checked);
expect(customUtils.getCheckboxState('X')).toBe(CheckboxState.Checked);
expect(customUtils.getCheckboxState('✓')).toBe(CheckboxState.Checked);
});
it('should return Unchecked for symbols in uncheckedSymbols', () => {
const customSettings = createSettings({ uncheckedSymbols: [' ', '_', '?'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState(' ')).toBe(CheckboxState.Unchecked);
expect(customUtils.getCheckboxState('_')).toBe(CheckboxState.Unchecked);
expect(customUtils.getCheckboxState('?')).toBe(CheckboxState.Unchecked);
});
it('should return Ignore for symbols in ignoreSymbols', () => {
const customSettings = createSettings({ ignoreSymbols: ['-', '~', '>'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState('-')).toBe(CheckboxState.Ignore);
expect(customUtils.getCheckboxState('~')).toBe(CheckboxState.Ignore);
expect(customUtils.getCheckboxState('>')).toBe(CheckboxState.Ignore);
});
it('should use unknownSymbolPolicy for symbols not in any list', () => {
const settingsChecked = createSettings({ unknownSymbolPolicy: CheckboxState.Checked });
const utilsChecked = new CheckboxUtils(settingsChecked);
expect(utilsChecked.getCheckboxState('?')).toBe(CheckboxState.Checked);
const settingsUnchecked = createSettings({ unknownSymbolPolicy: CheckboxState.Unchecked });
const utilsUnchecked = new CheckboxUtils(settingsUnchecked);
expect(utilsUnchecked.getCheckboxState('?')).toBe(CheckboxState.Unchecked);
const settingsIgnore = createSettings({ unknownSymbolPolicy: CheckboxState.Ignore });
const utilsIgnore = new CheckboxUtils(settingsIgnore);
expect(utilsIgnore.getCheckboxState('?')).toBe(CheckboxState.Ignore);
});
});
// --- Тесты для updateLineCheckboxStateWithInfo ---
describe('updateLineCheckboxStateWithInfo', () => {
it('should update checkbox state from unchecked to checked', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Assume valid line
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
// Uses the first symbol from settings.checkedSymbols ('x' by default)
expect(updatedLine).toBe('- [x] Task');
});
it('should update checkbox state from checked to unchecked', () => {
const line = '* [x] Done';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo);
// Uses the first symbol from settings.uncheckedSymbols (' ' by default)
expect(updatedLine).toBe('* [ ] Done');
});
it('should use first symbol from settings for update', () => {
const customSettings = createSettings({ checkedSymbols: ['V', 'X'], uncheckedSymbols: ['O', ' '] });
const customUtils = new CheckboxUtils(customSettings);
const lineToCheck = '- [O] Task';
const infoToCheck = customUtils.matchCheckboxLine(lineToCheck)!;
const updatedToCheck = customUtils.updateLineCheckboxStateWithInfo(lineToCheck, true, infoToCheck);
expect(updatedToCheck).toBe('- [V] Task'); // Should use 'V'
const lineToUncheck = '* [V] Done';
const infoToUncheck = customUtils.matchCheckboxLine(lineToUncheck)!;
const updatedToUncheck = customUtils.updateLineCheckboxStateWithInfo(lineToUncheck, false, infoToUncheck);
expect(updatedToUncheck).toBe('* [O] Done'); // Should use 'O'
});
it('should handle indented lines correctly', () => {
const line = ' - [ ] Indented';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
expect(updatedLine).toBe(' - [x] Indented');
});
it('should return original line if position is invalid (simulated)', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
// Simulate invalid position
const invalidInfo = { ...lineInfo, checkboxCharPosition: 100 };
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo);
expect(updatedLine).toBe(line); // Should not change
});
it('should use default checked symbol "x" if checkedSymbols setting is empty', () => {
// Создаем настройки с пустым списком checkedSymbols
const customSettings = createSettings({ checkedSymbols: [] });
const customUtils = new CheckboxUtils(customSettings);
const line = '- [ ] Task';
const lineInfo = customUtils.matchCheckboxLine(line)!;
// Пытаемся отметить чекбокс
const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
expect(updatedLine).toBe('- [x] Task'); // Ожидаем дефолтный 'x'
});
it('should use default unchecked symbol " " if uncheckedSymbols setting is empty', () => {
// Создаем настройки с пустым списком uncheckedSymbols
const customSettings = createSettings({ uncheckedSymbols: [] });
const customUtils = new CheckboxUtils(customSettings);
const line = '- [x] Task'; // Используем 'x' из дефолтных checkedSymbols
const lineInfo = customUtils.matchCheckboxLine(line)!;
// Пытаемся снять отметку
const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo);
expect(updatedLine).toBe('- [ ] Task'); // Ожидаем дефолтный ' ' (пробел)
});
// Усиленный тест для невалидной позиции (с проверкой console.warn)
it('should return original line and warn if position is invalid', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
// Мокаем console.warn
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
// Simulate invalid position
const invalidInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция
const updatedLineNegative = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo);
expect(updatedLineNegative).toBe(line); // Должен вернуть оригинальную строку
expect(warnSpy).toHaveBeenCalledTimes(1); // Проверяем вызов warn
const invalidInfo2 = { ...lineInfo, checkboxCharPosition: 100 }; // Другая невалидная позиция
const updatedLineOob = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo2);
expect(updatedLineOob).toBe(line); // Должен вернуть оригинальную строку
expect(warnSpy).toHaveBeenCalledTimes(2); // Проверяем вызов warn еще раз
// Восстанавливаем оригинальную функцию console.warn
warnSpy.mockRestore();
});
it('should return original line and warn if checkboxCharPosition is invalid for a valid checkbox type', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Это валидный чекбокс
const invalidPosInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidPosInfo);
expect(updatedLine).toBe(line);
expect(warnSpy).toHaveBeenCalledWith("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line);
warnSpy.mockRestore();
});
it('UPDATE_LINE: should return original line and warn if lineInfo is for NoCheckbox', () => {
const line = "- Plain item";
// Создаем lineInfo, как будто он пришел от plain list item
const plainLineInfo: CheckboxLineInfo = {
indent: 0,
marker: '-',
checkboxState: CheckboxState.NoCheckbox, // Ключевой момент
listItemText: 'Plain item',
// checkboxCharPosition не важен, т.к. до него не дойдет
};
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, plainLineInfo);
expect(updatedLine).toBe(line); // Строка не должна измениться
expect(warnSpy).toHaveBeenCalledWith("updateLineCheckboxStateWithInfo: Invalid lineInfo(no checkbox) for line:", line);
warnSpy.mockRestore();
});
});
// --- Тесты для propagateStateToChildren ---
describe('propagateStateToChildren', () => {
const setupUtils = (customSettings?: Partial<CheckboxSyncPluginSettings>) => {
return new CheckboxUtils(createSettings(customSettings));
}
it('should check children when parent is checked', () => {
const text = [
'- [x] Parent', // line 0
' - [ ] Child 1',
' - [ ] Grandchild',
' - [ ] Child 2',
'- [ ] Sibling',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child 1',
' - [x] Grandchild',
' - [x] Child 2',
'- [ ] Sibling', // Sibling should not change
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should uncheck children when parent is unchecked', () => {
const text = [
'- [ ] Parent', // line 0
' - [x] Child 1',
' - [x] Grandchild',
' - [ ] Child 2', // Already unchecked
'- [x] Sibling',
].join('\n');
const expected = [
'- [ ] Parent',
' - [ ] Child 1',
' - [ ] Grandchild',
' - [ ] Child 2',
'- [x] Sibling', // Sibling should not change
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should stop propagation at siblings or less indented lines', () => {
const text = [
' - [x] Parent', // line 0
' - [ ] Child',
' - [ ] Sibling', // Same indent level
'- [ ] Less Indented',
].join('\n');
const expected = [
' - [x] Parent',
' - [x] Child', // Changed
' - [ ] Sibling', // Not changed
'- [ ] Less Indented', // Not changed
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should skip ignored children and their subtrees', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [x] Parent', // 0
' - [ ] Child 1', // 1
' - [~] Ignored Child',// 2 (ignore)
' - [ ] Skipped GC', // 3 (skipped because parent ignored)
' - [ ] Child 2', // 4
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child 1', // Changed
' - [~] Ignored Child',// Unchanged
' - [ ] Skipped GC', // Unchanged
' - [x] Child 2', // Changed
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should do nothing if the parent line is not a checkbox', () => {
const text = [
'Parent Line',
' - [ ] Child',
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text);
});
it('should do nothing if the parent checkbox is ignored', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [~] Ignored Parent', // 0
' - [ ] Child',
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(text);
});
it('should return original text and warn if parent line index does not contain a list item', () => {
const text = "Not a list item\n - [ ] Child";
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
const result = checkboxUtils.propagateStateToChildren(text, 0);
expect(result).toBe(text);
expect(warnSpy).toHaveBeenCalledWith("checkbox not found in line 0");
warnSpy.mockRestore();
});
it('should stop propagation if a non-list-item line is encountered among children', () => {
const text = [
'- [x] Parent',
' Not a child list item', // This will make childLineInfo null
' - [ ] Grandchild (should not be processed)',
].join('\n');
const expected = [
'- [x] Parent',
' Not a child list item',
' - [ ] Grandchild (should not be processed)',
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should correctly stop processing sub-children of an ignored node if a non-list-item is found', () => {
const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] }));
const text = [
'- [x] Parent',
' - [~] Ignored Child',
' - [ ] Valid Sub-Child (will be skipped by inner loop)', // j инкрементируется
' Not a sub-child list item', // subChildLineInfo = null, inner loop breaks
' - [ ] Another Sub-Child (SHOULD NOT BE SKIPPED by inner loop, as it breaks, and also not processed by outer loop)',
' - [ ] Next Sibling of Ignored', // This should also not be processed due to outer loop break
].join('\n');
// Ожидаем, что Parent и Ignored Child останутся, а остальное не изменится,
// потому что внешний цикл прервется после обработки "Not a sub-child list item"
const expected = [
'- [x] Parent',
' - [~] Ignored Child',
' - [ ] Valid Sub-Child (will be skipped by inner loop)',
' Not a sub-child list item',
' - [ ] Another Sub-Child (SHOULD NOT BE SKIPPED by inner loop, as it breaks, and also not processed by outer loop)',
' - [ ] Next Sibling of Ignored',
].join('\n');
const result = utils.propagateStateToChildren(text, 0);
expect(result).toBe(expected);
// В данном случае, поскольку Parent [x], и мы ничего не меняем, результат будет равен text.
// Важно то, что Next Sibling of Ignored не стал [x].
});
});
// --- Тесты для propagateStateFromChildren ---
describe('propagateStateFromChildren', () => {
const setupUtils = (customSettings?: Partial<CheckboxSyncPluginSettings>) => {
return new CheckboxUtils(createSettings(customSettings));
}
it('should check parent if all children are checked', () => {
const text = [
'- [ ] Parent', // line 0
' - [x] Child 1',
' - [x] Child 2',
' - [x] Grandchild', // Needs parent (Child 2) to be checked first
].join('\n');
// Expected: Grandchild affects Child 2 -> Child 2 and Child 1 affect Parent
const expectedPass1 = [ // After processing Grandchild and Child 2
'- [ ] Parent',
' - [x] Child 1',
' - [x] Child 2', // Stays checked
' - [x] Grandchild',
].join('\n');
const expectedPass2 = [ // After processing Child 1 and Parent
'- [x] Parent', // Changes to checked
' - [x] Child 1',
' - [x] Child 2',
' - [x] Grandchild',
].join('\n');
// Since it processes bottom-up, Child 2 should already be evaluated correctly
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expectedPass2);
});
it('should uncheck parent if any child is unchecked', () => {
const text = [
'- [x] Parent', // line 0
' - [x] Child 1',
' - [ ] Child 2', // This one makes the parent unchecked
' - [ ] Grandchild',
].join('\n');
const expected = [
'- [ ] Parent', // Changes to unchecked
' - [x] Child 1',
' - [ ] Child 2',
' - [ ] Grandchild',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should handle nested updates correctly (bottom-up)', () => {
const text = [
'- [ ] Grandparent', // 0
' - [ ] Parent 1', // 1
' - [x] Child 1.1',// 2
' - [x] Child 1.2',// 3
' - [x] Parent 2', // 4
' - [ ] Child 2.1',// 5 (Makes Parent 2 unchecked)
].join('\n');
const expected = [
'- [ ] Grandparent', // Becomes unchecked (because Parent 2 becomes unchecked)
' - [x] Parent 1', // Becomes checked (Child 1.1, 1.2 are checked)
' - [x] Child 1.1',
' - [x] Child 1.2',
' - [ ] Parent 2', // Becomes unchecked (Child 2.1 is unchecked)
' - [ ] Child 2.1',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should not change parent state if it has no relevant children', () => {
const text = [
'- [ ] Parent',
'Sibling', // Not a child checkbox
' Not indented correctly',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text);
});
it('should ignore non-checkbox lines when determining parent state', () => {
const text = [
'- [ ] Parent',
' - [x] Child 1',
' Just text',
' - [x] Child 2',
].join('\n');
const expected = [
'- [x] Parent', // Should become checked
' - [x] Child 1',
' Just text',
' - [x] Child 2',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should skip ignored children when determining parent state', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [ ] Parent', // 0
' - [x] Child 1', // 1
' - [~] Ignored Child',// 2 (skipped)
' - [ ] Skipped GC', // 3 (doesn't affect parent)
' - [x] Child 2', // 4
].join('\n');
const expected = [
'- [x] Parent', // Becomes checked (based on Child 1 and Child 2 only)
' - [x] Child 1',
' - [~] Ignored Child',
' - [ ] Skipped GC',
' - [x] Child 2',
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(expected);
});
it('should not change parent state if all direct children are ignored', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [ ] Parent', // 0
' - [~] Ignored 1', // 1
' - [~] Ignored 2', // 2
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent stays unchecked
const text2 = [
'- [x] Parent', // 0
' - [~] Ignored 1', // 1
' - [~] Ignored 2', // 2
].join('\n');
expect(utils.propagateStateFromChildren(text2)).toBe(text2); // Parent stays checked
});
it('should not update ignored parents', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [~] Ignored Parent', // 0
' - [x] Child 1',
' - [x] Child 2',
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent remains ignored
});
it('should stop searching children when a line with equal or lesser indent is found', () => {
const text = [
'- [ ] Parent 1', // indent 0
'Some regular text', // indent 0. Stops search for Parent 1 children.
' - [x] Child 1.1', // indent 2. Should be ignored for Parent 1.
'- [ ] Parent 2', // indent 0. Processed independently.
' - [x] Child 2.1', // indent 2. Child of Parent 2.
].join('\n');
const result = checkboxUtils.propagateStateFromChildren(text);
const expected = [
'- [ ] Parent 1', // Unchanged (no children found before stop)
'Some regular text',
' - [x] Child 1.1',
'- [x] Parent 2', // Updated by Child 2.1
' - [x] Child 2.1',
].join('\n');
expect(result).toBe(expected);
});
it('should stop searching children when a sibling checkbox (equal/lesser indent) is found', () => {
const text = [
'- [ ] Parent 1', // indent 0
'- [ ] Sibling Parent', // indent 0. Stops search for Parent 1 children.
' - [x] Child SP.1', // indent 2. Belongs to Sibling Parent.
].join('\n');
const result = checkboxUtils.propagateStateFromChildren(text);
const expected = [
'- [ ] Parent 1', // Unchanged (no children found before stop)
'- [x] Sibling Parent', // Updated by Child SP.1
' - [x] Child SP.1',
].join('\n');
expect(result).toBe(expected);
const text2 = [
' - [ ] Parent 1', // indent 2
' - [x] Child 1.1', // indent 4
' - [ ] Sibling Parent', // indent 2. Stops search for Parent 1 children.
' - [ ] Child SP.1', // indent 4. Belongs to Sibling Parent.
].join('\n');
const result2 = checkboxUtils.propagateStateFromChildren(text2);
const expected2 = [
' - [x] Parent 1', // Updated by Child 1.1
' - [x] Child 1.1',
' - [ ] Sibling Parent', // Unchanged (Child SP.1 is unchecked)
' - [ ] Child SP.1',
].join('\n');
expect(result2).toBe(expected2);
});
it('should ignore non-list-item lines when iterating upwards', () => {
const text = [
'- [ ] Parent',
' - [x] Child',
'Just some random text in between', // parentLineInfo will be null for this
'- [ ] Another Parent',
' - [x] Another Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child',
'Just some random text in between',
'- [x] Another Parent',
' - [x] Another Child',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
});
// --- Тесты для syncText ---
describe('syncText', () => {
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textAfterParentChecked = [ // Simulate user checking parent
'- [x] Parent',
' - [ ] Child',
].join('\n');
const textAfterChildChecked = [ // Simulate user checking child
'- [ ] Parent',
' - [x] Child',
].join('\n');
it('should propagate down only if child sync enabled and diff matches', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: false, // Parent sync off
}));
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expected);
});
it('should propagate up only if parent sync enabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: false, // Child sync off
enableAutomaticParentState: true,
}));
const expected = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
// syncText calls propagateFromChildren unconditionally if enabled
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expected);
});
it('should propagate down then up if both enabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
// 1. User checks parent: '- [x] Parent', ' - [ ] Child'
// 2. Propagate down: '- [x] Parent', ' - [x] Child'
// 3. Propagate up: (no change needed as parent is already checked)
const expectedDown = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expectedDown);
// 1. User checks child: '- [ ] Parent', ' - [x] Child'
// 2. Propagate down: (no change, as only one line changed, and it wasn't the parent)
// 3. Propagate up: '- [x] Parent', ' - [x] Child'
const expectedUp = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expectedUp);
});
it('should do nothing if both syncs disabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: false,
enableAutomaticParentState: false,
}));
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(textAfterParentChecked);
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(textAfterChildChecked);
});
it('should not propagate down if diff is not a single checkbox state change', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true, // Down propagation enabled
enableAutomaticParentState: false,
}));
const textMultipleChanges = [
'- [x] Parent Changed', // Text changed too
' - [x] Child also changed',
].join('\n');
const textIndentChange = [
' - [x] Parent', // Indent changed
' - [ ] Child',
].join('\n');
// Multiple lines changed
expect(utils.syncText(textMultipleChanges, textBefore)).toBe(textMultipleChanges);
// Indent changed (diff > 1 line index)
expect(utils.syncText(textIndentChange, textBefore)).toBe(textIndentChange);
});
});
// --- Тесты для propagateStateToChildrenFromSingleDiff ---
describe('propagateStateToChildrenFromSingleDiff', () => {
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
it('should propagate if only checkbox state changed', () => {
const textAfter = [
'- [x] Parent', // Only state changed
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected);
});
it('should propagate if text content also changed', () => {
const textAfter = [
'- [x] Parent Changed Text', // State and text changed
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent Changed Text',
' - [x] Child', // Propagated
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected);
});
it('should NOT propagate if marker changed', () => {
const textAfter = [
'* [x] Parent', // Marker changed from - to *
' - [ ] Child',
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if indentation changed', () => {
const textAfter = [
' - [x] Parent', // Indentation changed
' - [ ] Child',
].join('\n');
// This will be detected as > 1 line difference by findDifferentLineIndexes
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if multiple lines changed', () => {
const textAfter = [
'- [x] Parent',
' - [x] Child', // Second line also changed
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if changed line is not a checkbox', () => {
const textBeforeNonCheck = "Line 1\nLine 2";
const textAfterNonCheck = "Line 1 changed\nLine 2";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterNonCheck, textBeforeNonCheck)).toBe(textAfterNonCheck);
});
it('should propagate if changed line is an ignored checkbox', () => {
const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] }));
const textBeforeIgnored = [
'- [~] Parent',
' - [ ] Child',
].join('\n');
const textAfterIgnored = [
'- [x] Parent', // Changed FROM ignored TO checked
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated
].join('\n');
expect(utils.propagateStateToChildrenFromSingleDiff(textAfterIgnored, textBeforeIgnored)).toBe(expected);
});
it('should return original text if textBefore is undefined', () => {
const text = '- [ ] Line';
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, undefined)).toBe(text);
});
it('should return original text if line counts differ', () => {
const text1 = '- [ ] Line1\n- [ ] Line2';
const text2 = '- [ ] Line1';
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text1, text2)).toBe(text1);
});
it('should return original text if textBefore and text have different line counts', () => {
const textBefore = "- [ ] Line 1";
const text = "- [ ] Line 1\n- [ ] Line 2";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, textBefore)).toBe(text);
});
it('should return original text if no lines changed (diffIndexes.length === 0)', () => {
const text = "- [ ] Task";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, text)).toBe(text);
});
it('should return original text if multiple lines changed (diffIndexes.length > 1)', () => {
const textBefore = "- [ ] Task1\n- [ ] Task2";
const textAfter = "- [x] Task1\n- [x] Task2"; // 2 diffs
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should not propagate if changed line was not a list item before', () => {
const textBefore = "Not a list item";
const textAfter = "- [x] Became a list item";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should not propagate if changed line is not a list item after', () => {
const textBefore = "- [ ] Was a list item";
const textAfter = "Not a list item anymore";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should not propagate if indent changed', () => {
const textBeforeIndent = [
"- [ ] Parent",
" - [ ] Child",
].join('\n');
const textAfterIndent = [
" - [ ] Parent",
" - [ ] Child",
].join('\n'); // Parent indent changed
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterIndent, textBeforeIndent)).toBe(textAfterIndent);
});
it('should not propagate if marker changed', () => {
const textBefore = "- [ ] Item\n - [ ] Child";
const textAfter = "* [ ] Item\n - [ ] Child"; // Marker changed
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should not propagate if checkbox state did not change (e.g., only text content)', () => {
const textBefore = "- [x] Task Alpha\n - [ ] Child";
const textAfter = "- [x] Task Bravo\n - [ ] Child"; // Only text changed
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
});
// --- Тесты для findDifferentLineIndexes ---
describe('findDifferentLineIndexes', () => {
it('should return empty array for identical lines', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['a', 'b', 'c'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([]);
});
it('should return index of the single different line', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['a', 'B', 'c'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([1]);
});
it('should return indexes of multiple different lines', () => {
const lines1 = ['a', 'b', 'c', 'd'];
const lines2 = ['A', 'b', 'C', 'd'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]);
});
it('should detect differences at start and end', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['X', 'b', 'Y'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]);
});
it('should throw error if line arrays have different lengths', () => {
const lines1 = ['a', 'b'];
const lines2 = ['a', 'b', 'c'];
expect(() => checkboxUtils.findDifferentLineIndexes(lines1, lines2))
.toThrow("the length of the lines must be equal");
expect(() => checkboxUtils.findDifferentLineIndexes(lines2, lines1))
.toThrow("the length of the lines must be equal");
});
});
});

View file

@ -0,0 +1,383 @@
// checkboxUtils.plainLists.test.ts
import { CheckboxUtils } from '../src/checkboxUtils';
import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types';
// Вспомогательная функция для создания настроек с переопределениями
const createSettings = (overrides: Partial<CheckboxSyncPluginSettings> = {}): Readonly<CheckboxSyncPluginSettings> => {
return { ...DEFAULT_SETTINGS, ...overrides } as Readonly<CheckboxSyncPluginSettings>;
};
describe('CheckboxUtils - Plain List Item Support', () => {
let checkboxUtils: CheckboxUtils;
let settings: Readonly<CheckboxSyncPluginSettings>;
beforeEach(() => {
settings = createSettings();
checkboxUtils = new CheckboxUtils(settings);
});
// --- Тесты для matchCheckboxLine с поддержкой plain list items ---
describe('matchCheckboxLine with Plain List Items', () => {
it('should match a plain bullet list item', () => {
const line = '- Plain item text';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '-',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Plain item text',
// Опциональные поля для чекбокса должны быть undefined
checkChar: undefined,
checkboxCharPosition: undefined,
isChecked: undefined,
});
});
it('should match a plain asterisk list item with indentation', () => {
const line = ' * Indented plain item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 2,
marker: '*',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Indented plain item',
});
});
it('should match a plain plus list item', () => {
const line = '+ Another plain item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '+',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Another plain item',
});
});
it('should match a plain numbered list item', () => {
const line = '1. Numbered plain item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '1.',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Numbered plain item',
});
});
it('should match a plain multi-digit numbered list item', () => {
const line = '10. Long numbered plain item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '10.',
checkboxState: CheckboxState.NoCheckbox,
listItemText: 'Long numbered plain item',
});
});
it('should still match a standard checkbox item', () => {
const line = '- [x] A checkbox item';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result?.checkboxState).toBe(CheckboxState.Checked);
expect(result?.isChecked).toBe(true);
expect(result?.checkChar).toBe('x');
expect(result?.listItemText).toBe('A checkbox item');
});
it('should match a plain list item with no text after marker', () => {
const line = '- '; // Marker and space
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '-',
checkboxState: CheckboxState.NoCheckbox,
listItemText: '', // Empty string for text
});
});
it('should match a plain list item with only spaces after marker', () => {
const line = '* '; // Marker and spaces
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '*',
checkboxState: CheckboxState.NoCheckbox,
listItemText: '', // Text is trimmed
});
});
it('should correctly parse listItemText for checkboxes', () => {
const line = '- [ ] Task with text';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result?.listItemText).toBe('Task with text');
const lineNoText = '- [x] ';
const resultNoText = checkboxUtils.matchCheckboxLine(lineNoText);
expect(resultNoText?.listItemText).toBe('');
});
it('should return null for lines that are not list items or malformed', () => {
expect(checkboxUtils.matchCheckboxLine('Just some text')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('-NoSpaceAfterMarker')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('1.NoSpaceAfterMarker')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('> Not a list marker')).toBeNull();
// Это все еще НЕ чекбокс и НЕ plain list item по текущим правилам (нет пробела после маркера)
expect(checkboxUtils.matchCheckboxLine('-[ ] No space after marker for checkbox')).toBeNull();
// Это plain list item, а не чекбокс, т.к. нет пробела перед [ ]
const trickyLine = "-[ ] Not a checkbox, but could be a plain item if regex allowed marker immediately followed by non-space";
// По текущему regex ^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$ для plain list, это будет null.
// Если бы regex для plain list был ^(\s*)([*+-]|\d+\.)\s*(.*)$, и мы бы отсекали чекбоксы отдельно,
// то "-[ ] text" мог бы считаться plain. Но текущий regex для plain требует \s+ после маркера.
// А regex для чекбокса требует " \[" . Так что "-[ ] text" не матчится ни тем, ни другим.
expect(checkboxUtils.matchCheckboxLine(trickyLine)).toBeNull();
// A line that looks like a checkbox but is missing the char inside brackets
expect(checkboxUtils.matchCheckboxLine('- [] Text')).toBeNull(); // Checkbox regex expects a char
// A line that looks like a checkbox but missing space after brackets (and thus missing listItemText part)
expect(checkboxUtils.matchCheckboxLine('- [x]')).toBeNull(); // Checkbox regex expects \s after [.]
});
});
// --- Тесты для propagateStateToChildren с Plain List Items ---
describe('propagateStateToChildren with Plain List Items', () => {
it('should not change plain list item children', () => {
const text = [
'- [x] Parent Checkbox',
' - Plain Child Item',
' * [ ] Another Checkbox Child',
' - Deep Plain Child',
].join('\n');
const expected = [
'- [x] Parent Checkbox',
' - Plain Child Item', // Unchanged
' * [x] Another Checkbox Child', // Changed
' - Deep Plain Child', // Unchanged
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should not propagate from a plain list item parent', () => {
const text = [
'- Parent Plain Item', // line 0
' - [ ] Child Checkbox',
' * Plain Grandchild',
].join('\n');
// No changes expected as parent is NoCheckbox
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text);
});
it('should skip ignored children and not skip childs plain list items correctly', () => {
const customSettings = createSettings({ ignoreSymbols: ['~'] });
const utils = new CheckboxUtils(customSettings);
const text = [
'- [x] Parent', // 0
' - Plain Child', // 1 (NoCheckbox)
' - [ ] Grandchild CB',// 2 (Checkbox under NoCheckbox, should be affected by Parent)
' - [~] Ignored Child', // 3 (Ignore)
' - [ ] Skipped GC', // 4 (Checkbox under Ignore, should not be affected)
' - [ ] Checkbox Child', // 5 (Checkbox)
].join('\n');
const expected = [
'- [x] Parent',
' - Plain Child', // Unchanged
' - [x] Grandchild CB',// Changed
' - [~] Ignored Child', // Unchanged
' - [ ] Skipped GC', // Unchanged
' - [x] Checkbox Child', // Changed
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should stop propagation if a non-list-item line is encountered (indent > parent)', () => {
const text = [
'- [x] Parent',
' This is just text, not a list item.', // childLineInfo будет null, indent > parent.indent. Цикл прервется.
' - [ ] Grandchild (should not be processed as loop breaks)',
].join('\n');
const expected = [
'- [x] Parent',
' This is just text, not a list item.',
' - [ ] Grandchild (should not be processed as loop breaks)',
].join('\n');
// Проверяем, что Grandchild не изменился
const result = checkboxUtils.propagateStateToChildren(text, 0);
expect(result).toBe(expected);
// Дополнительно можно проверить, что matchCheckboxLine для "Grandchild" не вызывался бы
// (если это важно, но для покрытия достаточно проверки результата)
});
});
// --- Тесты для propagateStateFromChildren с Plain List Items ---
describe('propagateStateFromChildren with Plain List Items', () => {
it('should not update a plain list item parent based on children', () => {
const text = [
'- Parent Plain Item',
' - [x] Child Checkbox 1',
' - [x] Child Checkbox 2',
].join('\n');
// Parent is NoCheckbox, so it should not change
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text);
});
it('should not ignore plain list item children when determining parent checkbox state', () => {
const text = [
'- [ ] Parent Checkbox',
' - Plain Child 1',
' - [x] Actual Checkbox Child',
' * Plain Child 2',
' - [ ] Grandchild (under plain, ignored for Parent Checkbox)',
].join('\n');
const expected = [
'- [ ] Parent Checkbox', // Becomes checked because "Actual Checkbox Child" is checked
' - Plain Child 1',
' - [x] Actual Checkbox Child',
' * Plain Child 2',
' - [ ] Grandchild (under plain, ignored for Parent Checkbox)',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('parent checkbox remains unchecked if all relevant children are plain or ignored', () => {
const customSettings = createSettings({ ignoreSymbols: ['~'] });
const utils = new CheckboxUtils(customSettings);
const text = [
'- [ ] Parent Checkbox',
' - Plain Child',
' - [~] Ignored Child',
].join('\n');
// No actual checkbox children to influence the parent
expect(utils.propagateStateFromChildren(text)).toBe(text);
const text2 = [
'- [x] Parent Checkbox Initially Checked',
' - Plain Child',
' - [~] Ignored Child',
].join('\n');
// Parent should remain checked as no relevant children to change it
expect(utils.propagateStateFromChildren(text2)).toBe(text2);
});
it('parent checkbox becomes unchecked if it has one unchecked checkbox child and other plain/ignored children', () => {
const text = [
'- [x] Parent Checkbox',
' - Plain Child',
' - [ ] Unchecked Checkbox Child', // This will make parent unchecked
' - [x] Checked Checkbox Child (but one is enough)',
].join('\n');
// Note: propagateStateFromChildren processes from bottom up.
// The order of children doesn't matter as much as their collective state.
// Let's simplify to ensure clarity:
const simplerText = [
'- [x] Parent Checkbox',
' - Plain Child',
' - [ ] Unchecked Checkbox Child',
].join('\n');
const expected = [
'- [ ] Parent Checkbox', // Becomes unchecked
' - Plain Child',
' - [ ] Unchecked Checkbox Child',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(simplerText)).toBe(expected);
});
it('handles mixed children correctly, bottom-up', () => {
const text = [
'- [ ] GP', // 0
' - Plain Child of GP', // 1
' - [ ] P1 (Checkbox)', // 2
' - [x] C1.1 (Checkbox)', // 3
' - Plain Grandchild', // 4
' - [x] P2 (Checkbox)', // 5 (initially checked)
' - Plain Grandchild 2', // 6
' - [ ] C2.1 (Unchecked CB)',// 7
].join('\n');
const expected = [
'- [ ] GP', // P1 becomes [x], P2 becomes [ ]. So GP is [ ]
' - Plain Child of GP',
' - [x] P1 (Checkbox)', // Becomes [x] due to C1.1
' - [x] C1.1 (Checkbox)',
' - Plain Grandchild',
' - [ ] P2 (Checkbox)', // Becomes [ ] due to C2.1
' - Plain Grandchild 2',
' - [ ] C2.1 (Unchecked CB)',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
});
// --- Тесты для syncText с Plain List Items (влияние на propagateStateToChildrenFromSingleDiff) ---
describe('syncText (and propagateStateToChildrenFromSingleDiff) with Plain List Items', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true, // Keep true to see full effect if needed, though child is main focus
}));
it('should propagate down if a line changes from Plain to Checked Checkbox', () => {
const textBefore = [
'- Parent Plain Item', // Was plain
' - [ ] Child Checkbox',
' - Plain Grandchild',
].join('\n');
const textAfter = [
'- [x] Parent Plain Item', // Now a checked checkbox
' - [ ] Child Checkbox',
' - Plain Grandchild',
].join('\n');
const expected = [
'- [x] Parent Plain Item',
' - [x] Child Checkbox', // Propagated
' - Plain Grandchild', // Unchanged (it's plain)
].join('\n');
expect(utils.syncText(textAfter, textBefore)).toBe(expected);
});
it('should NOT propagate down if a line changes from Checked Checkbox to Plain', () => {
const textBefore = [
'- [x] Parent Checkbox', // Was checkbox
' - [ ] Child Checkbox', // This would have been checked by propagation
' - Plain Grandchild',
].join('\n');
const textAfter = [
'- Parent Checkbox', // Now plain
' - [ ] Child Checkbox',
' - Plain Grandchild',
].join('\n');
// propagateStateToChildrenFromSingleDiff will call propagateStateToChildren
// propagateStateToChildren will see the parent is NoCheckbox and do nothing.
// Then propagateStateFromChildren runs, but parent is NoCheckbox.
expect(utils.syncText(textAfter, textBefore)).toBe(textAfter);
});
it('should correctly propagate up if a child changes, parent is checkbox, and siblings are plain', () => {
const textBefore = [
'- [ ] Parent Checkbox',
' - Plain Sibling',
' - [ ] Child Checkbox that changes',
].join('\n');
const textAfter = [
'- [ ] Parent Checkbox',
' - Plain Sibling',
' - [x] Child Checkbox that changes', // User checks this
].join('\n');
const expected = [
'- [x] Parent Checkbox', // Propagates up
' - Plain Sibling',
' - [x] Child Checkbox that changes',
].join('\n');
expect(utils.syncText(textAfter, textBefore)).toBe(expected);
});
});
});

View file

@ -1,774 +0,0 @@
import { CheckboxUtils } from '../src/checkboxUtils'; // Путь к вашему файлу
import { CheckboxState, CheckboxSyncPluginSettings, DEFAULT_SETTINGS } from '../src/types'; // Путь к вашему файлу типов
// Вспомогательная функция для создания настроек с переопределениями
const createSettings = (overrides: Partial<CheckboxSyncPluginSettings> = {}): Readonly<CheckboxSyncPluginSettings> => {
return { ...DEFAULT_SETTINGS, ...overrides } as Readonly<CheckboxSyncPluginSettings>;
};
describe('CheckboxUtils', () => {
let checkboxUtils: CheckboxUtils;
let settings: Readonly<CheckboxSyncPluginSettings>;
beforeEach(() => {
// Используем настройки по умолчанию для большинства тестов
settings = createSettings();
checkboxUtils = new CheckboxUtils(settings);
});
// --- Тесты для matchCheckboxLine ---
describe('matchCheckboxLine', () => {
it('should match standard unchecked checkbox', () => {
const line = '- [ ] Task';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '-',
checkChar: ' ',
checkboxCharPosition: 3,
checkboxState: CheckboxState.Unchecked,
isChecked: false,
});
});
it('should match standard checked checkbox', () => {
const line = '* [x] Done';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '*',
checkChar: 'x',
checkboxCharPosition: 3,
checkboxState: CheckboxState.Checked,
isChecked: true,
});
});
it('should match checkbox with indentation', () => {
const line = ' + [x] Indented Task';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 2,
marker: '+',
checkChar: 'x',
checkboxCharPosition: 5, // 2 spaces + 1 marker + 1 space + 1 [ = 5
checkboxState: CheckboxState.Checked,
isChecked: true,
});
});
it('should match numbered list checkbox', () => {
const line = '1. [ ] Numbered';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '1.',
checkChar: ' ',
checkboxCharPosition: 4, // 2 marker + 1 space + 1 [ = 4
checkboxState: CheckboxState.Unchecked,
isChecked: false,
});
});
it('should match checkbox with multi-digit numbered list', () => {
const line = '10. [x] Double Digit';
const result = checkboxUtils.matchCheckboxLine(line);
expect(result).not.toBeNull();
expect(result).toEqual({
indent: 0,
marker: '10.',
checkChar: 'x',
checkboxCharPosition: 5, // 3 marker + 1 space + 1 [ = 5
checkboxState: CheckboxState.Checked,
isChecked: true,
});
});
it('should return null for lines without checkbox format', () => {
expect(checkboxUtils.matchCheckboxLine('Just text')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('- Not a checkbox')).toBeNull();
expect(checkboxUtils.matchCheckboxLine('- [ ]')).toBeNull(); // No space after ]
expect(checkboxUtils.matchCheckboxLine('- [] ')).toBeNull(); // No char inside
expect(checkboxUtils.matchCheckboxLine('[ ] Task')).toBeNull(); // No marker
expect(checkboxUtils.matchCheckboxLine('-[ ] Task')).toBeNull(); // No space after marker
});
it('should match custom symbols based on settings', () => {
const customSettings = createSettings({
checkedSymbols: ['X', 'V'],
uncheckedSymbols: ['O', '-'],
ignoreSymbols: ['~'],
});
const customUtils = new CheckboxUtils(customSettings);
const checkedLine = '- [V] Custom Checked';
const checkedResult = customUtils.matchCheckboxLine(checkedLine);
expect(checkedResult?.checkboxState).toBe(CheckboxState.Checked);
expect(checkedResult?.isChecked).toBe(true);
expect(checkedResult?.checkChar).toBe('V');
const uncheckedLine = '* [O] Custom Unchecked';
const uncheckedResult = customUtils.matchCheckboxLine(uncheckedLine);
expect(uncheckedResult?.checkboxState).toBe(CheckboxState.Unchecked);
expect(uncheckedResult?.isChecked).toBe(false);
expect(uncheckedResult?.checkChar).toBe('O');
const ignoredLine = '+ [~] Custom Ignored';
const ignoredResult = customUtils.matchCheckboxLine(ignoredLine);
expect(ignoredResult?.checkboxState).toBe(CheckboxState.Ignore);
expect(ignoredResult?.isChecked).toBeUndefined();
expect(ignoredResult?.checkChar).toBe('~');
});
});
// --- Тесты для getCheckboxState ---
describe('getCheckboxState', () => {
it('should return Checked for symbols in checkedSymbols', () => {
const customSettings = createSettings({ checkedSymbols: ['x', 'X', '✓'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState('x')).toBe(CheckboxState.Checked);
expect(customUtils.getCheckboxState('X')).toBe(CheckboxState.Checked);
expect(customUtils.getCheckboxState('✓')).toBe(CheckboxState.Checked);
});
it('should return Unchecked for symbols in uncheckedSymbols', () => {
const customSettings = createSettings({ uncheckedSymbols: [' ', '_', '?'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState(' ')).toBe(CheckboxState.Unchecked);
expect(customUtils.getCheckboxState('_')).toBe(CheckboxState.Unchecked);
expect(customUtils.getCheckboxState('?')).toBe(CheckboxState.Unchecked);
});
it('should return Ignore for symbols in ignoreSymbols', () => {
const customSettings = createSettings({ ignoreSymbols: ['-', '~', '>'] });
const customUtils = new CheckboxUtils(customSettings);
expect(customUtils.getCheckboxState('-')).toBe(CheckboxState.Ignore);
expect(customUtils.getCheckboxState('~')).toBe(CheckboxState.Ignore);
expect(customUtils.getCheckboxState('>')).toBe(CheckboxState.Ignore);
});
it('should use unknownSymbolPolicy for symbols not in any list', () => {
const settingsChecked = createSettings({ unknownSymbolPolicy: CheckboxState.Checked });
const utilsChecked = new CheckboxUtils(settingsChecked);
expect(utilsChecked.getCheckboxState('?')).toBe(CheckboxState.Checked);
const settingsUnchecked = createSettings({ unknownSymbolPolicy: CheckboxState.Unchecked });
const utilsUnchecked = new CheckboxUtils(settingsUnchecked);
expect(utilsUnchecked.getCheckboxState('?')).toBe(CheckboxState.Unchecked);
const settingsIgnore = createSettings({ unknownSymbolPolicy: CheckboxState.Ignore });
const utilsIgnore = new CheckboxUtils(settingsIgnore);
expect(utilsIgnore.getCheckboxState('?')).toBe(CheckboxState.Ignore);
});
});
// --- Тесты для updateLineCheckboxStateWithInfo ---
describe('updateLineCheckboxStateWithInfo', () => {
it('should update checkbox state from unchecked to checked', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!; // Assume valid line
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
// Uses the first symbol from settings.checkedSymbols ('x' by default)
expect(updatedLine).toBe('- [x] Task');
});
it('should update checkbox state from checked to unchecked', () => {
const line = '* [x] Done';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo);
// Uses the first symbol from settings.uncheckedSymbols (' ' by default)
expect(updatedLine).toBe('* [ ] Done');
});
it('should use first symbol from settings for update', () => {
const customSettings = createSettings({ checkedSymbols: ['V', 'X'], uncheckedSymbols: ['O', ' '] });
const customUtils = new CheckboxUtils(customSettings);
const lineToCheck = '- [O] Task';
const infoToCheck = customUtils.matchCheckboxLine(lineToCheck)!;
const updatedToCheck = customUtils.updateLineCheckboxStateWithInfo(lineToCheck, true, infoToCheck);
expect(updatedToCheck).toBe('- [V] Task'); // Should use 'V'
const lineToUncheck = '* [V] Done';
const infoToUncheck = customUtils.matchCheckboxLine(lineToUncheck)!;
const updatedToUncheck = customUtils.updateLineCheckboxStateWithInfo(lineToUncheck, false, infoToUncheck);
expect(updatedToUncheck).toBe('* [O] Done'); // Should use 'O'
});
it('should handle indented lines correctly', () => {
const line = ' - [ ] Indented';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
expect(updatedLine).toBe(' - [x] Indented');
});
it('should return original line if position is invalid (simulated)', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
// Simulate invalid position
const invalidInfo = { ...lineInfo, checkboxCharPosition: 100 };
const updatedLine = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo);
expect(updatedLine).toBe(line); // Should not change
});
it('should use default checked symbol "x" if checkedSymbols setting is empty', () => {
// Создаем настройки с пустым списком checkedSymbols
const customSettings = createSettings({ checkedSymbols: [] });
const customUtils = new CheckboxUtils(customSettings);
const line = '- [ ] Task';
const lineInfo = customUtils.matchCheckboxLine(line)!;
// Пытаемся отметить чекбокс
const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, true, lineInfo);
expect(updatedLine).toBe('- [x] Task'); // Ожидаем дефолтный 'x'
});
it('should use default unchecked symbol " " if uncheckedSymbols setting is empty', () => {
// Создаем настройки с пустым списком uncheckedSymbols
const customSettings = createSettings({ uncheckedSymbols: [] });
const customUtils = new CheckboxUtils(customSettings);
const line = '- [x] Task'; // Используем 'x' из дефолтных checkedSymbols
const lineInfo = customUtils.matchCheckboxLine(line)!;
// Пытаемся снять отметку
const updatedLine = customUtils.updateLineCheckboxStateWithInfo(line, false, lineInfo);
expect(updatedLine).toBe('- [ ] Task'); // Ожидаем дефолтный ' ' (пробел)
});
// Усиленный тест для невалидной позиции (с проверкой console.warn)
it('should return original line and warn if position is invalid', () => {
const line = '- [ ] Task';
const lineInfo = checkboxUtils.matchCheckboxLine(line)!;
// Мокаем console.warn
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { });
// Simulate invalid position
const invalidInfo = { ...lineInfo, checkboxCharPosition: -1 }; // Невалидная позиция
const updatedLineNegative = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo);
expect(updatedLineNegative).toBe(line); // Должен вернуть оригинальную строку
expect(warnSpy).toHaveBeenCalledTimes(1); // Проверяем вызов warn
const invalidInfo2 = { ...lineInfo, checkboxCharPosition: 100 }; // Другая невалидная позиция
const updatedLineOob = checkboxUtils.updateLineCheckboxStateWithInfo(line, true, invalidInfo2);
expect(updatedLineOob).toBe(line); // Должен вернуть оригинальную строку
expect(warnSpy).toHaveBeenCalledTimes(2); // Проверяем вызов warn еще раз
// Восстанавливаем оригинальную функцию console.warn
warnSpy.mockRestore();
});
});
// --- Тесты для propagateStateToChildren ---
describe('propagateStateToChildren', () => {
const setupUtils = (customSettings?: Partial<CheckboxSyncPluginSettings>) => {
return new CheckboxUtils(createSettings(customSettings));
}
it('should check children when parent is checked', () => {
const text = [
'- [x] Parent', // line 0
' - [ ] Child 1',
' - [ ] Grandchild',
' - [ ] Child 2',
'- [ ] Sibling',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child 1',
' - [x] Grandchild',
' - [x] Child 2',
'- [ ] Sibling', // Sibling should not change
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should uncheck children when parent is unchecked', () => {
const text = [
'- [ ] Parent', // line 0
' - [x] Child 1',
' - [x] Grandchild',
' - [ ] Child 2', // Already unchecked
'- [x] Sibling',
].join('\n');
const expected = [
'- [ ] Parent',
' - [ ] Child 1',
' - [ ] Grandchild',
' - [ ] Child 2',
'- [x] Sibling', // Sibling should not change
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should stop propagation at siblings or less indented lines', () => {
const text = [
' - [x] Parent', // line 0
' - [ ] Child',
' - [ ] Sibling', // Same indent level
'- [ ] Less Indented',
].join('\n');
const expected = [
' - [x] Parent',
' - [x] Child', // Changed
' - [ ] Sibling', // Not changed
'- [ ] Less Indented', // Not changed
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should skip ignored children and their subtrees', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [x] Parent', // 0
' - [ ] Child 1', // 1
' - [~] Ignored Child',// 2 (ignore)
' - [ ] Skipped GC', // 3 (skipped because parent ignored)
' - [ ] Child 2', // 4
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child 1', // Changed
' - [~] Ignored Child',// Unchanged
' - [ ] Skipped GC', // Unchanged
' - [x] Child 2', // Changed
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(expected);
});
it('should do nothing if the parent line is not a checkbox', () => {
const text = [
'Parent Line',
' - [ ] Child',
].join('\n');
expect(checkboxUtils.propagateStateToChildren(text, 0)).toBe(text);
});
it('should do nothing if the parent checkbox is ignored', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [~] Ignored Parent', // 0
' - [ ] Child',
].join('\n');
expect(utils.propagateStateToChildren(text, 0)).toBe(text);
});
});
// --- Тесты для propagateStateFromChildren ---
describe('propagateStateFromChildren', () => {
const setupUtils = (customSettings?: Partial<CheckboxSyncPluginSettings>) => {
return new CheckboxUtils(createSettings(customSettings));
}
it('should check parent if all children are checked', () => {
const text = [
'- [ ] Parent', // line 0
' - [x] Child 1',
' - [x] Child 2',
' - [x] Grandchild', // Needs parent (Child 2) to be checked first
].join('\n');
// Expected: Grandchild affects Child 2 -> Child 2 and Child 1 affect Parent
const expectedPass1 = [ // After processing Grandchild and Child 2
'- [ ] Parent',
' - [x] Child 1',
' - [x] Child 2', // Stays checked
' - [x] Grandchild',
].join('\n');
const expectedPass2 = [ // After processing Child 1 and Parent
'- [x] Parent', // Changes to checked
' - [x] Child 1',
' - [x] Child 2',
' - [x] Grandchild',
].join('\n');
// Since it processes bottom-up, Child 2 should already be evaluated correctly
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expectedPass2);
});
it('should uncheck parent if any child is unchecked', () => {
const text = [
'- [x] Parent', // line 0
' - [x] Child 1',
' - [ ] Child 2', // This one makes the parent unchecked
' - [ ] Grandchild',
].join('\n');
const expected = [
'- [ ] Parent', // Changes to unchecked
' - [x] Child 1',
' - [ ] Child 2',
' - [ ] Grandchild',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should handle nested updates correctly (bottom-up)', () => {
const text = [
'- [ ] Grandparent', // 0
' - [ ] Parent 1', // 1
' - [x] Child 1.1',// 2
' - [x] Child 1.2',// 3
' - [x] Parent 2', // 4
' - [ ] Child 2.1',// 5 (Makes Parent 2 unchecked)
].join('\n');
const expected = [
'- [ ] Grandparent', // Becomes unchecked (because Parent 2 becomes unchecked)
' - [x] Parent 1', // Becomes checked (Child 1.1, 1.2 are checked)
' - [x] Child 1.1',
' - [x] Child 1.2',
' - [ ] Parent 2', // Becomes unchecked (Child 2.1 is unchecked)
' - [ ] Child 2.1',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should not change parent state if it has no relevant children', () => {
const text = [
'- [ ] Parent',
'Sibling', // Not a child checkbox
' Not indented correctly',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(text);
});
it('should ignore non-checkbox lines when determining parent state', () => {
const text = [
'- [ ] Parent',
' - [x] Child 1',
' Just text',
' - [x] Child 2',
].join('\n');
const expected = [
'- [x] Parent', // Should become checked
' - [x] Child 1',
' Just text',
' - [x] Child 2',
].join('\n');
expect(checkboxUtils.propagateStateFromChildren(text)).toBe(expected);
});
it('should skip ignored children when determining parent state', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [ ] Parent', // 0
' - [x] Child 1', // 1
' - [~] Ignored Child',// 2 (skipped)
' - [ ] Skipped GC', // 3 (doesn't affect parent)
' - [x] Child 2', // 4
].join('\n');
const expected = [
'- [x] Parent', // Becomes checked (based on Child 1 and Child 2 only)
' - [x] Child 1',
' - [~] Ignored Child',
' - [ ] Skipped GC',
' - [x] Child 2',
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(expected);
});
it('should not change parent state if all direct children are ignored', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [ ] Parent', // 0
' - [~] Ignored 1', // 1
' - [~] Ignored 2', // 2
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent stays unchecked
const text2 = [
'- [x] Parent', // 0
' - [~] Ignored 1', // 1
' - [~] Ignored 2', // 2
].join('\n');
expect(utils.propagateStateFromChildren(text2)).toBe(text2); // Parent stays checked
});
it('should not update ignored parents', () => {
const utils = setupUtils({ ignoreSymbols: ['~'] });
const text = [
'- [~] Ignored Parent', // 0
' - [x] Child 1',
' - [x] Child 2',
].join('\n');
expect(utils.propagateStateFromChildren(text)).toBe(text); // Parent remains ignored
});
it('should stop searching children when a line with equal or lesser indent is found', () => {
const text = [
'- [ ] Parent 1', // indent 0
'Some regular text', // indent 0. Stops search for Parent 1 children.
' - [x] Child 1.1', // indent 2. Should be ignored for Parent 1.
'- [ ] Parent 2', // indent 0. Processed independently.
' - [x] Child 2.1', // indent 2. Child of Parent 2.
].join('\n');
const result = checkboxUtils.propagateStateFromChildren(text);
const expected = [
'- [ ] Parent 1', // Unchanged (no children found before stop)
'Some regular text',
' - [x] Child 1.1',
'- [x] Parent 2', // Updated by Child 2.1
' - [x] Child 2.1',
].join('\n');
expect(result).toBe(expected);
});
it('should stop searching children when a sibling checkbox (equal/lesser indent) is found', () => {
const text = [
'- [ ] Parent 1', // indent 0
'- [ ] Sibling Parent', // indent 0. Stops search for Parent 1 children.
' - [x] Child SP.1', // indent 2. Belongs to Sibling Parent.
].join('\n');
const result = checkboxUtils.propagateStateFromChildren(text);
const expected = [
'- [ ] Parent 1', // Unchanged (no children found before stop)
'- [x] Sibling Parent', // Updated by Child SP.1
' - [x] Child SP.1',
].join('\n');
expect(result).toBe(expected);
const text2 = [
' - [ ] Parent 1', // indent 2
' - [x] Child 1.1', // indent 4
' - [ ] Sibling Parent', // indent 2. Stops search for Parent 1 children.
' - [ ] Child SP.1', // indent 4. Belongs to Sibling Parent.
].join('\n');
const result2 = checkboxUtils.propagateStateFromChildren(text2);
const expected2 = [
' - [x] Parent 1', // Updated by Child 1.1
' - [x] Child 1.1',
' - [ ] Sibling Parent', // Unchanged (Child SP.1 is unchecked)
' - [ ] Child SP.1',
].join('\n');
expect(result2).toBe(expected2);
});
});
// --- Тесты для syncText ---
describe('syncText', () => {
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
const textAfterParentChecked = [ // Simulate user checking parent
'- [x] Parent',
' - [ ] Child',
].join('\n');
const textAfterChildChecked = [ // Simulate user checking child
'- [ ] Parent',
' - [x] Child',
].join('\n');
it('should propagate down only if child sync enabled and diff matches', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: false, // Parent sync off
}));
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expected);
});
it('should propagate up only if parent sync enabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: false, // Child sync off
enableAutomaticParentState: true,
}));
const expected = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
// syncText calls propagateFromChildren unconditionally if enabled
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expected);
});
it('should propagate down then up if both enabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true,
enableAutomaticParentState: true,
}));
// 1. User checks parent: '- [x] Parent', ' - [ ] Child'
// 2. Propagate down: '- [x] Parent', ' - [x] Child'
// 3. Propagate up: (no change needed as parent is already checked)
const expectedDown = [
'- [x] Parent',
' - [x] Child', // Propagated down
].join('\n');
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(expectedDown);
// 1. User checks child: '- [ ] Parent', ' - [x] Child'
// 2. Propagate down: (no change, as only one line changed, and it wasn't the parent)
// 3. Propagate up: '- [x] Parent', ' - [x] Child'
const expectedUp = [
'- [x] Parent', // Propagated up
' - [x] Child',
].join('\n');
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(expectedUp);
});
it('should do nothing if both syncs disabled', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: false,
enableAutomaticParentState: false,
}));
expect(utils.syncText(textAfterParentChecked, textBefore)).toBe(textAfterParentChecked);
expect(utils.syncText(textAfterChildChecked, textBefore)).toBe(textAfterChildChecked);
});
it('should not propagate down if diff is not a single checkbox state change', () => {
const utils = new CheckboxUtils(createSettings({
enableAutomaticChildState: true, // Down propagation enabled
enableAutomaticParentState: false,
}));
const textMultipleChanges = [
'- [x] Parent Changed', // Text changed too
' - [x] Child also changed',
].join('\n');
const textIndentChange = [
' - [x] Parent', // Indent changed
' - [ ] Child',
].join('\n');
// Multiple lines changed
expect(utils.syncText(textMultipleChanges, textBefore)).toBe(textMultipleChanges);
// Indent changed (diff > 1 line index)
expect(utils.syncText(textIndentChange, textBefore)).toBe(textIndentChange);
});
});
// --- Тесты для propagateStateToChildrenFromSingleDiff ---
describe('propagateStateToChildrenFromSingleDiff', () => {
const textBefore = [
'- [ ] Parent',
' - [ ] Child',
].join('\n');
it('should propagate if only checkbox state changed', () => {
const textAfter = [
'- [x] Parent', // Only state changed
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected);
});
it('should propagate if text content also changed', () => {
const textAfter = [
'- [x] Parent Changed Text', // State and text changed
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent Changed Text',
' - [x] Child', // Propagated
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(expected);
});
it('should NOT propagate if marker changed', () => {
const textAfter = [
'* [x] Parent', // Marker changed from - to *
' - [ ] Child',
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if indentation changed', () => {
const textAfter = [
' - [x] Parent', // Indentation changed
' - [ ] Child',
].join('\n');
// This will be detected as > 1 line difference by findDifferentLineIndexes
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if multiple lines changed', () => {
const textAfter = [
'- [x] Parent',
' - [x] Child', // Second line also changed
].join('\n');
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfter, textBefore)).toBe(textAfter);
});
it('should NOT propagate if changed line is not a checkbox', () => {
const textBeforeNonCheck = "Line 1\nLine 2";
const textAfterNonCheck = "Line 1 changed\nLine 2";
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(textAfterNonCheck, textBeforeNonCheck)).toBe(textAfterNonCheck);
});
it('should propagate if changed line is an ignored checkbox', () => {
const utils = new CheckboxUtils(createSettings({ ignoreSymbols: ['~'] }));
const textBeforeIgnored = [
'- [~] Parent',
' - [ ] Child',
].join('\n');
const textAfterIgnored = [
'- [x] Parent', // Changed FROM ignored TO checked
' - [ ] Child',
].join('\n');
const expected = [
'- [x] Parent',
' - [x] Child', // Propagated
].join('\n');
expect(utils.propagateStateToChildrenFromSingleDiff(textAfterIgnored, textBeforeIgnored)).toBe(expected);
});
it('should return original text if textBefore is undefined', () => {
const text = '- [ ] Line';
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text, undefined)).toBe(text);
});
it('should return original text if line counts differ', () => {
const text1 = '- [ ] Line1\n- [ ] Line2';
const text2 = '- [ ] Line1';
expect(checkboxUtils.propagateStateToChildrenFromSingleDiff(text1, text2)).toBe(text1);
});
});
// --- Тесты для findDifferentLineIndexes ---
describe('findDifferentLineIndexes', () => {
it('should return empty array for identical lines', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['a', 'b', 'c'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([]);
});
it('should return index of the single different line', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['a', 'B', 'c'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([1]);
});
it('should return indexes of multiple different lines', () => {
const lines1 = ['a', 'b', 'c', 'd'];
const lines2 = ['A', 'b', 'C', 'd'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]);
});
it('should detect differences at start and end', () => {
const lines1 = ['a', 'b', 'c'];
const lines2 = ['X', 'b', 'Y'];
expect(checkboxUtils.findDifferentLineIndexes(lines1, lines2)).toEqual([0, 2]);
});
it('should throw error if line arrays have different lengths', () => {
const lines1 = ['a', 'b'];
const lines2 = ['a', 'b', 'c'];
expect(() => checkboxUtils.findDifferentLineIndexes(lines1, lines2))
.toThrow("the length of the lines must be equal");
expect(() => checkboxUtils.findDifferentLineIndexes(lines2, lines1))
.toThrow("the length of the lines must be equal");
});
});
});

View file

@ -17,7 +17,8 @@ const config: Config = {
coverageDirectory: "coverage",
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// coverageProvider: "v8",
coverageProvider: "babel",
// An array of file extensions your modules use
moduleFileExtensions: [

View file

@ -1,212 +1,246 @@
import { CheckboxState, CheckboxSyncPluginSettings } from "./types";
export interface CheckboxLineInfo {
indent: number;
marker: string;
checkChar: string;
checkboxCharPosition: number;
checkboxState: CheckboxState;
isChecked: boolean | undefined;
indent: number;
marker: string;
checkChar?: string;
checkboxCharPosition?: number;
checkboxState: CheckboxState;
isChecked?: boolean | undefined;
listItemText?: string;
}
export class CheckboxUtils {
private settings: Readonly<CheckboxSyncPluginSettings>;
constructor(settings: Readonly<CheckboxSyncPluginSettings>) {
this.settings = settings;
}
private settings: Readonly<CheckboxSyncPluginSettings>;
constructor(settings: Readonly<CheckboxSyncPluginSettings>) {
this.settings = settings;
}
matchCheckboxLine(line: string): CheckboxLineInfo | null {
const match = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s/);
if (!match) {
return null;
}
const indent = match[1].length;
const marker = match[2];
const checkChar = match[3];
const checkboxCharPosition = indent + marker.length + 2;
const checkboxState = this.getCheckboxState(checkChar);
let isChecked = checkboxState === CheckboxState.Ignore ? undefined : checkboxState === CheckboxState.Checked;
matchCheckboxLine(line: string): CheckboxLineInfo | null {
// const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\](\s.*)?$/);
// const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\](\s|$)(.*)$/);
const checkboxMatch = line.match(/^(\s*)([*+-]|\d+\.) \[(.)\]\s(.*)$/);
if (checkboxMatch) {
const indent = checkboxMatch[1].length;
const marker = checkboxMatch[2];
const checkChar = checkboxMatch[3];
const checkboxCharPosition = indent + marker.length + 2;
const checkboxState = this.getCheckboxState(checkChar);
const isChecked = checkboxState === CheckboxState.Ignore ? undefined : checkboxState === CheckboxState.Checked;
const listItemText = (checkboxMatch[4] || "").trimStart();
return {
indent,
marker,
checkChar,
checkboxCharPosition,
checkboxState,
isChecked
};
}
return {
indent,
marker,
checkChar,
checkboxCharPosition,
checkboxState,
isChecked,
listItemText
};
}
const listItemMatch = line.match(/^(\s*)([*+-]|\d+\.)\s+(?!\[.?\])(.*)$/);
if (listItemMatch) {
const indent = listItemMatch[1].length;
const marker = listItemMatch[2];
const listItemText = listItemMatch[3].trimStart();
getCheckboxState(text: string): CheckboxState {
if (this.settings.checkedSymbols.includes(text)) {
return CheckboxState.Checked;
}
if (this.settings.uncheckedSymbols.includes(text)) {
return CheckboxState.Unchecked;
}
if (this.settings.ignoreSymbols.includes(text)) {
return CheckboxState.Ignore;
}
return this.settings.unknownSymbolPolicy;
}
return {
indent,
marker,
// checkChar, checkboxCharPosition, isChecked - не применимы
checkboxState: CheckboxState.NoCheckbox,
listItemText
};
}
return null;
}
updateLineCheckboxStateWithInfo(line: string, shouldBeChecked: boolean, lineInfo: CheckboxLineInfo): string {
const checkedChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт
const uncheckedChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт
getCheckboxState(text: string): CheckboxState {
if (this.settings.checkedSymbols.includes(text)) {
return CheckboxState.Checked;
}
if (this.settings.uncheckedSymbols.includes(text)) {
return CheckboxState.Unchecked;
}
if (this.settings.ignoreSymbols.includes(text)) {
return CheckboxState.Ignore;
}
return this.settings.unknownSymbolPolicy;
}
const newCheckChar = shouldBeChecked ? checkedChar : uncheckedChar;
const pos = lineInfo.checkboxCharPosition;
if (pos >= 0 && pos < line.length) {
return line.substring(0, pos) + newCheckChar + line.substring(pos + 1);
}
console.warn("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line);
return line;
}
updateLineCheckboxStateWithInfo(line: string, shouldBeChecked: boolean, lineInfo: CheckboxLineInfo): string {
if (lineInfo.checkboxState !== CheckboxState.NoCheckbox) {
const checkedChar = this.settings.checkedSymbols.length > 0 ? this.settings.checkedSymbols[0] : 'x'; // 'x' как дефолт
const uncheckedChar = this.settings.uncheckedSymbols.length > 0 ? this.settings.uncheckedSymbols[0] : ' '; // ' ' как дефолт
propagateStateToChildren(text: string, parentLine: number): string {
// console.log("propagateStateToChildren");
const newCheckChar = shouldBeChecked ? checkedChar : uncheckedChar;
const pos = lineInfo.checkboxCharPosition!;
if (pos >= 0 && pos < line.length) {
return line.substring(0, pos) + newCheckChar + line.substring(pos + 1);
} else {
console.warn("updateLineCheckboxStateWithInfo: Invalid checkbox position in lineInfo for line:", line);
return line;
}
}
console.warn("updateLineCheckboxStateWithInfo: Invalid lineInfo(no checkbox) for line:", line);
return line;
}
const lines = text.split("\n");
propagateStateToChildren(text: string, parentLine: number): string {
const lines = text.split("\n");
const parentLineInfo = this.matchCheckboxLine(lines[parentLine]);
const parentLineInfo = this.matchCheckboxLine(lines[parentLine]);
if (!parentLineInfo) {
console.warn(`checkbox not found in line ${parentLine}`)
return text;
}
if (!parentLineInfo) {
console.warn(`checkbox not found in line ${parentLine}`)
return text;
}
if (parentLineInfo.checkboxState === CheckboxState.Ignore) {
return text;
}
let parentIsChecked = parentLineInfo.isChecked!;
if (parentLineInfo.checkboxState === CheckboxState.Ignore ||
parentLineInfo.checkboxState === CheckboxState.NoCheckbox ||
parentLineInfo.isChecked === undefined) {
return text;
}
const parentIsChecked = parentLineInfo.isChecked;
let j = parentLine + 1;
let j = parentLine + 1;
while (j < lines.length) {
const childText = lines[j];
const childLineInfo = this.matchCheckboxLine(childText);
if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break;
while (j < lines.length) {
const childText = lines[j];
const childLineInfo = this.matchCheckboxLine(childText);
if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) {
break;
}
if (childLineInfo.checkboxState !== CheckboxState.Ignore) {
lines[j] = this.updateLineCheckboxStateWithInfo(lines[j], parentIsChecked, childLineInfo);
j++;
} else {
//skip children ignore node
j++;
while (j < lines.length) {
const subChildLineInfo = this.matchCheckboxLine(lines[j]);
if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) {
break;
};
j++;
}
}
}
return lines.join("\n");
}
if (childLineInfo.checkboxState === CheckboxState.Ignore) {
//skip children ignore node
j++;
while (j < lines.length) {
const subChildLineInfo = this.matchCheckboxLine(lines[j]);
if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) {
break;
};
j++;
}
} else if (childLineInfo.checkboxState === CheckboxState.NoCheckbox) {
j++;
} else {
lines[j] = this.updateLineCheckboxStateWithInfo(lines[j], parentIsChecked, childLineInfo);
j++;
}
}
return lines.join("\n");
}
propagateStateFromChildren(text: string): string {
const lines = text.split("\n");
propagateStateFromChildren(text: string): string {
const lines = text.split("\n");
for (let i = lines.length - 1; i >= 0; i--) {
const parentLineInfo = this.matchCheckboxLine(lines[i]);
if (!parentLineInfo) continue;
for (let i = lines.length - 1; i >= 0; i--) {
const parentLineInfo = this.matchCheckboxLine(lines[i]);
if (!parentLineInfo) continue;
if (parentLineInfo.checkboxState === CheckboxState.Ignore) {
continue;
}
const parentIsChecked = parentLineInfo.isChecked!;
let allChildrenChecked = true;
let hasChildren = false;
if (
parentLineInfo.checkboxState === CheckboxState.Ignore ||
parentLineInfo.checkboxState === CheckboxState.NoCheckbox
) {
continue;
}
const parentIsChecked = parentLineInfo.isChecked!;
let allRelevantChildrenChecked = true;
let hasRelevantChildren = false;
for (let j = i + 1; j < lines.length; j++) {
const childLineInfo = this.matchCheckboxLine(lines[j]);
if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break;
for (let j = i + 1; j < lines.length; j++) {
const childLineInfo = this.matchCheckboxLine(lines[j]);
if (childLineInfo.checkboxState === CheckboxState.Ignore) {
while (j + 1 < lines.length) {
const subChildLineInfo = this.matchCheckboxLine(lines[j + 1]);
if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) {
break;
};
j++;
}
continue;
}
hasChildren = true;
const childrenIsChecked = childLineInfo.isChecked!;
if (!childrenIsChecked) {
allChildrenChecked = false;
break;
}
}
if (!childLineInfo || childLineInfo.indent <= parentLineInfo.indent) break;
if (hasChildren && parentIsChecked !== allChildrenChecked) {
lines[i] = this.updateLineCheckboxStateWithInfo(lines[i], allChildrenChecked, parentLineInfo);
}
}
return lines.join("\n");
}
if (childLineInfo.checkboxState === CheckboxState.Ignore) {
while (j + 1 < lines.length) {
const subChildLineInfo = this.matchCheckboxLine(lines[j + 1]);
if (!subChildLineInfo || subChildLineInfo.indent <= childLineInfo.indent) {
break;
};
j++;
}
continue;
}
if (childLineInfo.checkboxState === CheckboxState.NoCheckbox) {
continue;
}
hasRelevantChildren = true;
const childrenIsChecked = childLineInfo.isChecked!;
if (!childrenIsChecked) {
allRelevantChildrenChecked = false;
break;
}
}
syncText(text: string, textBefore: string | undefined): string {
let newText = text;
if (this.settings.enableAutomaticChildState) {
newText = this.propagateStateToChildrenFromSingleDiff(text, textBefore);
}
if (this.settings.enableAutomaticParentState) {
newText = this.propagateStateFromChildren(newText);
}
return newText;
}
if (hasRelevantChildren && parentIsChecked !== allRelevantChildrenChecked) {
lines[i] = this.updateLineCheckboxStateWithInfo(lines[i], allRelevantChildrenChecked, parentLineInfo);
}
}
return lines.join("\n");
}
propagateStateToChildrenFromSingleDiff(text: string, textBefore: string | undefined): string {
if (!textBefore) return text;
const textBeforeLines = textBefore.split('\n');
const textLines = text.split('\n');
syncText(text: string, textBefore: string | undefined): string {
let newText = text;
if (this.settings.enableAutomaticChildState) {
newText = this.propagateStateToChildrenFromSingleDiff(text, textBefore);
}
if (this.settings.enableAutomaticParentState) {
newText = this.propagateStateFromChildren(newText);
}
return newText;
}
if (textBeforeLines.length !== textLines.length) return text;
propagateStateToChildrenFromSingleDiff(text: string, textBefore: string | undefined): string {
if (!textBefore) return text;
const textBeforeLines = textBefore.split('\n');
const textLines = text.split('\n');
const diffIndexes = this.findDifferentLineIndexes(textBeforeLines, textLines);
if (diffIndexes.length !== 1) {
return text;
}
if (textBeforeLines.length !== textLines.length) return text;
const index = diffIndexes[0];
const diffIndexes = this.findDifferentLineIndexes(textBeforeLines, textLines);
if (diffIndexes.length !== 1) {
return text;
}
const lineBefore = textBeforeLines[index];
const lineAfter = textLines[index];
// Получаем информацию о чекбоксе ДО и ПОСЛЕ изменения
const lineInfoBefore = this.matchCheckboxLine(lineBefore);
const lineInfoAfter = this.matchCheckboxLine(lineAfter);
// Проверяем, что:
// 1. Обе строки (до и после) являются валидными строками с чекбоксом.
// 2. Отступ и маркер списка не изменились (т.е. структура строки та же).
// 3. Изменился именно символ внутри скобок [ ].
if (
lineInfoBefore && lineInfoAfter &&
lineInfoBefore.indent === lineInfoAfter.indent &&
lineInfoBefore.marker === lineInfoAfter.marker &&
lineInfoBefore.checkboxState !== lineInfoAfter.checkboxState
) {
return this.propagateStateToChildren(text, diffIndexes[0]);
}
return text;
const index = diffIndexes[0];
}
const lineBefore = textBeforeLines[index];
const lineAfter = textLines[index];
// Получаем информацию о чекбоксе ДО и ПОСЛЕ изменения
const lineInfoBefore = this.matchCheckboxLine(lineBefore);
const lineInfoAfter = this.matchCheckboxLine(lineAfter);
// Проверяем, что:
// 1. Обе строки (до и после) являются валидными строками с чекбоксом.
// 2. Отступ и маркер списка не изменились (т.е. структура строки та же).
// 3. Изменился именно символ внутри скобок [ ].
if (
lineInfoBefore && lineInfoAfter &&
lineInfoBefore.indent === lineInfoAfter.indent &&
lineInfoBefore.marker === lineInfoAfter.marker &&
lineInfoBefore.checkboxState !== lineInfoAfter.checkboxState
) {
return this.propagateStateToChildren(text, diffIndexes[0]);
}
return text;
findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] {
if (lines1.length !== lines2.length) {
throw new Error("the length of the lines must be equal");
}
}
const length = lines1.length;
const result: number[] = [];
for (let i = 0; i < length; i++) {
if (lines1[i] !== lines2[i]) {
result.push(i);
}
}
return result;
}
findDifferentLineIndexes(lines1: string[], lines2: string[]): number[] {
if (lines1.length !== lines2.length) {
throw new Error("the length of the lines must be equal");
}
}
const length = lines1.length;
const result: number[] = [];
for (let i = 0; i < length; i++) {
if (lines1[i] !== lines2[i]) {
result.push(i);
}
}
return result;
}
}

View file

@ -13,7 +13,8 @@ export interface CheckboxSyncPluginSettings {
export enum CheckboxState {
Checked = 'checked',
Unchecked = 'unchecked',
Ignore = 'ignore'
Ignore = 'ignore',
NoCheckbox = "noCheckbox"
}
export const DEFAULT_SETTINGS: CheckboxSyncPluginSettings = {