diff --git a/src/__tests__/tasks/data-view-collection.test.ts b/src/__tests__/tasks/data-view-collection.test.ts index 1edaa17..7ed1f0d 100644 --- a/src/__tests__/tasks/data-view-collection.test.ts +++ b/src/__tests__/tasks/data-view-collection.test.ts @@ -340,17 +340,24 @@ describe('DataView task collection', () => { expect(result).toContain(' - [ ] Child level 2'); }); - it('handles orphaned sub-tasks when parent indent is skipped', () => { - // Task at level 2 without a level 1 parent - should be added as top-level - sut = new DataViewTaskCollection( - '## Header 1\n\n- [ ] Parent\n\t\t- [ ] Orphaned child at level 2\n' - ); + it('finds nearest parent when indent levels are skipped', () => { + // Task at level 2 without a level 1 parent - should find level 0 parent + sut = new DataViewTaskCollection('## Header 1\n\n- [ ] Parent\n\t\t- [ ] Child at level 2\n'); const tasks = sut.getAllTasks(); - // The orphaned task should be added as a top-level task since there's no level 1 parent - expect(tasks.length).toEqual(2); + expect(tasks.length).toEqual(1); expect(tasks[0].getName()).toEqual('Parent'); - expect(tasks[1].getName()).toEqual('Orphaned child at level 2'); + expect(tasks[0].hasChildren()).toBe(true); + expect(tasks[0].getChildren()[0].getName()).toEqual('Child at level 2'); + }); + + it('handles truly orphaned sub-tasks with no parent available', () => { + // Indented task at start of section with no parent - should be added as top-level + sut = new DataViewTaskCollection('## Header 1\n\n\t- [ ] Orphaned task with no parent\n'); + + const tasks = sut.getAllTasks(); + expect(tasks.length).toEqual(1); + expect(tasks[0].getName()).toEqual('Orphaned task with no parent'); }); it('handles task with due date in toString when dueDate is set', () => { diff --git a/src/__tests__/tasks/emoji-collection.test.ts b/src/__tests__/tasks/emoji-collection.test.ts index 1b64b63..f5d238c 100644 --- a/src/__tests__/tasks/emoji-collection.test.ts +++ b/src/__tests__/tasks/emoji-collection.test.ts @@ -394,17 +394,54 @@ describe('Emoji task collection', () => { expect(result).toContain(' - [ ] Child level 2'); }); - it('handles orphaned sub-tasks when parent indent is skipped', () => { - // Task at level 2 without a level 1 parent - should be added as top-level + it('parses sub-tasks with 4-space indentation (issue #63)', () => { + // 4-space indentation calculates as level 2, but should still find level 0 parent sut = new EmojiTaskCollection( - '## Header 1\n\n- [ ] Parent\n\t\t- [ ] Orphaned child at level 2\n' + '## Header 1\n\n- [ ] Perl Training setup\n - [x] Initial build ✅ 2026-01-15\n - [ ] Add in extra features\n - [ ] Decide upon the activities\n' ); const tasks = sut.getAllTasks(); - // The orphaned task should be added as a top-level task since there's no level 1 parent - expect(tasks.length).toEqual(2); + expect(tasks.length).toEqual(1); + expect(tasks[0].getName()).toEqual('Perl Training setup'); + expect(tasks[0].hasChildren()).toBe(true); + expect(tasks[0].getChildren().length).toEqual(3); + expect(tasks[0].getChildren()[0].getName()).toEqual('Initial build'); + expect(tasks[0].getChildren()[1].getName()).toEqual('Add in extra features'); + expect(tasks[0].getChildren()[2].getName()).toEqual('Decide upon the activities'); + }); + + it('preserves 4-space indentation pattern when outputting', () => { + sut = new EmojiTaskCollection( + '## Header 1\n\n- [ ] Parent\n - [ ] Child with 4-space indent\n' + ); + + const tasks = sut.getAllTasks(); + expect(tasks.length).toEqual(1); + expect(tasks[0].hasChildren()).toBe(true); + + const result = tasks[0].toString(); + expect(result).toContain('- [ ] Parent'); + expect(result).toContain(' - [ ] Child with 4-space indent'); + }); + + it('finds nearest parent when indent levels are skipped', () => { + // Task at level 2 without a level 1 parent - should find level 0 parent + sut = new EmojiTaskCollection('## Header 1\n\n- [ ] Parent\n\t\t- [ ] Child at level 2\n'); + + const tasks = sut.getAllTasks(); + expect(tasks.length).toEqual(1); expect(tasks[0].getName()).toEqual('Parent'); - expect(tasks[1].getName()).toEqual('Orphaned child at level 2'); + expect(tasks[0].hasChildren()).toBe(true); + expect(tasks[0].getChildren()[0].getName()).toEqual('Child at level 2'); + }); + + it('handles truly orphaned sub-tasks with no parent available', () => { + // Indented task at start of section with no parent - should be added as top-level + sut = new EmojiTaskCollection('## Header 1\n\n\t- [ ] Orphaned task with no parent\n'); + + const tasks = sut.getAllTasks(); + expect(tasks.length).toEqual(1); + expect(tasks[0].getName()).toEqual('Orphaned task with no parent'); }); it('handles task with due date in toString when dueDate is set', () => { diff --git a/src/tasks/collection.ts b/src/tasks/collection.ts index a784ba7..b68d40c 100644 --- a/src/tasks/collection.ts +++ b/src/tasks/collection.ts @@ -37,8 +37,11 @@ export abstract class TaskCollection { parentStack[0] = task; } else { // Nested task: find the appropriate parent - // Parent is at indentLevel - 1 in the stack - const parentIndex = indentLevel - 1; + // Search backwards from indentLevel - 1 to find the nearest parent + let parentIndex = indentLevel - 1; + while (parentIndex >= 0 && !parentStack[parentIndex]) { + parentIndex--; + } if (parentIndex >= 0 && parentStack[parentIndex]) { parentStack[parentIndex].addChild(task); } else { diff --git a/src/tasks/task.ts b/src/tasks/task.ts index 9d4eaac..9b812dc 100644 --- a/src/tasks/task.ts +++ b/src/tasks/task.ts @@ -42,12 +42,12 @@ export abstract class Task { const match = this.line.match(/^(\s*)/); if (match && match[1]) { this.indent = match[1]; - // Calculate indent level: count tabs as 1, or every 2-4 spaces as 1 + // Calculate indent level: count tabs as 1, or spaces as levels if (this.indent.includes('\t')) { this.indentLevel = (this.indent.match(/\t/g) || []).length; } else { - // Assume 2-space or 4-space indentation, use 2 as minimum - this.indentLevel = Math.floor(this.indent.length / 2); + // Any non-zero indentation is at least level 1, then add levels for every 2 spaces + this.indentLevel = Math.max(1, Math.floor(this.indent.length / 2)); } } }