digital-tasks 2.0.2 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,451 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseMarkdown, toMarkdown, syncStatusFromMarkdown, task, parallel, sequential, createProject, } from '../src/index.js';
3
+ describe('Markdown Parser', () => {
4
+ describe('parseMarkdown()', () => {
5
+ it('should parse project name from h1', () => {
6
+ const markdown = `# My Project
7
+
8
+ - [ ] Task 1
9
+ `;
10
+ const project = parseMarkdown(markdown);
11
+ expect(project.name).toBe('My Project');
12
+ });
13
+ it('should parse parallel tasks from unordered list', () => {
14
+ const markdown = `# Project
15
+
16
+ - [ ] Task A
17
+ - [ ] Task B
18
+ - [ ] Task C
19
+ `;
20
+ const project = parseMarkdown(markdown);
21
+ expect(project.tasks).toHaveLength(1);
22
+ expect(project.tasks[0].__type).toBe('parallel');
23
+ expect(project.tasks[0].tasks).toHaveLength(3);
24
+ });
25
+ it('should parse sequential tasks from ordered list', () => {
26
+ const markdown = `# Project
27
+
28
+ 1. [ ] Step 1
29
+ 2. [ ] Step 2
30
+ 3. [ ] Step 3
31
+ `;
32
+ const project = parseMarkdown(markdown);
33
+ expect(project.tasks).toHaveLength(1);
34
+ expect(project.tasks[0].__type).toBe('sequential');
35
+ expect(project.tasks[0].tasks).toHaveLength(3);
36
+ });
37
+ it('should parse task status from checkboxes', () => {
38
+ const markdown = `# Project
39
+
40
+ - [ ] Pending
41
+ - [x] Completed
42
+ - [-] In progress
43
+ - [~] Blocked
44
+ - [!] Failed
45
+ `;
46
+ const project = parseMarkdown(markdown);
47
+ const tasks = project.tasks[0].tasks;
48
+ expect(tasks[0].metadata?._originalStatus).toBe('pending');
49
+ expect(tasks[1].metadata?._originalStatus).toBe('completed');
50
+ expect(tasks[2].metadata?._originalStatus).toBe('in_progress');
51
+ expect(tasks[3].metadata?._originalStatus).toBe('blocked');
52
+ expect(tasks[4].metadata?._originalStatus).toBe('failed');
53
+ });
54
+ it('should parse priority markers', () => {
55
+ const markdown = `# Project
56
+
57
+ - [ ] !!Critical task
58
+ - [ ] !Urgent task
59
+ - [ ] ^High priority
60
+ - [ ] Normal task
61
+ - [ ] vLow priority
62
+ `;
63
+ const project = parseMarkdown(markdown);
64
+ const tasks = project.tasks[0].tasks;
65
+ expect(tasks[0].priority).toBe('critical');
66
+ expect(tasks[0].title).toBe('Critical task');
67
+ expect(tasks[1].priority).toBe('urgent');
68
+ expect(tasks[2].priority).toBe('high');
69
+ expect(tasks[3].priority).toBe('normal');
70
+ expect(tasks[4].priority).toBe('low');
71
+ });
72
+ it('should parse nested subtasks', () => {
73
+ const markdown = `# Project
74
+
75
+ - [ ] Parent task
76
+ - [ ] Subtask 1
77
+ - [ ] Subtask 2
78
+ `;
79
+ const project = parseMarkdown(markdown);
80
+ const parent = (project.tasks[0].tasks[0]);
81
+ expect(parent.title).toBe('Parent task');
82
+ expect(parent.subtasks).toHaveLength(2);
83
+ });
84
+ it('should parse sections with h2 headings', () => {
85
+ const markdown = `# Project
86
+
87
+ ## Planning
88
+ - [ ] Design
89
+ - [ ] Spec
90
+
91
+ ## Implementation (sequential)
92
+ 1. [ ] Backend
93
+ 2. [ ] Frontend
94
+ `;
95
+ const project = parseMarkdown(markdown);
96
+ // Parser creates groups based on task types, sections affect mode detection
97
+ expect(project.tasks.length).toBeGreaterThanOrEqual(1);
98
+ // First section tasks should be parsed
99
+ let foundDesign = false;
100
+ let foundBackend = false;
101
+ function findTasks(tasks) {
102
+ for (const t of tasks) {
103
+ if (t.__type === 'task') {
104
+ if (t.title === 'Design')
105
+ foundDesign = true;
106
+ if (t.title === 'Backend')
107
+ foundBackend = true;
108
+ }
109
+ else if (t.tasks) {
110
+ findTasks(t.tasks);
111
+ }
112
+ }
113
+ }
114
+ findTasks(project.tasks);
115
+ expect(foundDesign).toBe(true);
116
+ expect(foundBackend).toBe(true);
117
+ });
118
+ it('should detect execution mode from section name', () => {
119
+ const markdown = `# Project
120
+
121
+ ## Tasks (parallel)
122
+ - [ ] Task 1
123
+ - [ ] Task 2
124
+ `;
125
+ const project = parseMarkdown(markdown);
126
+ // Section name with (parallel) should create parallel group
127
+ expect(project.tasks.length).toBeGreaterThanOrEqual(1);
128
+ // Find the tasks
129
+ let foundTask1 = false;
130
+ function findTasks(tasks) {
131
+ for (const t of tasks) {
132
+ if (t.__type === 'task' && t.title === 'Task 1')
133
+ foundTask1 = true;
134
+ else if (t.tasks)
135
+ findTasks(t.tasks);
136
+ }
137
+ }
138
+ findTasks(project.tasks);
139
+ expect(foundTask1).toBe(true);
140
+ });
141
+ it('should handle empty markdown', () => {
142
+ const project = parseMarkdown('');
143
+ expect(project.name).toBe('Untitled Project');
144
+ expect(project.tasks).toEqual([]);
145
+ });
146
+ it('should handle markdown with only headings', () => {
147
+ const markdown = `# My Project
148
+
149
+ ## Section 1
150
+
151
+ ## Section 2
152
+ `;
153
+ const project = parseMarkdown(markdown);
154
+ expect(project.name).toBe('My Project');
155
+ expect(project.tasks).toEqual([]);
156
+ });
157
+ });
158
+ describe('toMarkdown()', () => {
159
+ it('should serialize project name as h1', () => {
160
+ const project = createProject({
161
+ name: 'Test Project',
162
+ tasks: [],
163
+ });
164
+ const markdown = toMarkdown(project);
165
+ expect(markdown).toContain('# Test Project');
166
+ });
167
+ it('should serialize project description', () => {
168
+ const project = createProject({
169
+ name: 'Test Project',
170
+ description: 'This is a description',
171
+ tasks: [],
172
+ });
173
+ const markdown = toMarkdown(project);
174
+ expect(markdown).toContain('This is a description');
175
+ });
176
+ it('should serialize parallel tasks as unordered list', () => {
177
+ const project = createProject({
178
+ name: 'Test',
179
+ tasks: [
180
+ parallel(task('Task A'), task('Task B')),
181
+ ],
182
+ });
183
+ const markdown = toMarkdown(project);
184
+ expect(markdown).toContain('- [ ] Task A');
185
+ expect(markdown).toContain('- [ ] Task B');
186
+ });
187
+ it('should serialize sequential tasks as ordered list', () => {
188
+ const project = createProject({
189
+ name: 'Test',
190
+ tasks: [
191
+ sequential(task('Step 1'), task('Step 2')),
192
+ ],
193
+ });
194
+ const markdown = toMarkdown(project);
195
+ expect(markdown).toContain('1. [ ] Step 1');
196
+ expect(markdown).toContain('2. [ ] Step 2');
197
+ });
198
+ it('should serialize task status', () => {
199
+ const project = createProject({
200
+ name: 'Test',
201
+ tasks: [
202
+ parallel(task('Pending', { metadata: { _originalStatus: 'pending' } }), task('Completed', { metadata: { _originalStatus: 'completed' } }), task('In Progress', { metadata: { _originalStatus: 'in_progress' } })),
203
+ ],
204
+ });
205
+ const markdown = toMarkdown(project);
206
+ expect(markdown).toContain('- [ ] Pending');
207
+ expect(markdown).toContain('- [x] Completed');
208
+ expect(markdown).toContain('- [-] In Progress');
209
+ });
210
+ it('should serialize priority markers when enabled', () => {
211
+ const project = createProject({
212
+ name: 'Test',
213
+ tasks: [
214
+ parallel(task('Critical', { priority: 'critical' }), task('Urgent', { priority: 'urgent' }), task('High', { priority: 'high' }), task('Normal', { priority: 'normal' }), task('Low', { priority: 'low' })),
215
+ ],
216
+ });
217
+ const markdown = toMarkdown(project, { includePriority: true });
218
+ expect(markdown).toContain('!!Critical');
219
+ expect(markdown).toContain('!Urgent');
220
+ expect(markdown).toContain('^High');
221
+ expect(markdown).toContain('vLow');
222
+ expect(markdown).toMatch(/\[ \] Normal/); // No marker for normal
223
+ });
224
+ it('should serialize subtasks with indentation', () => {
225
+ const project = createProject({
226
+ name: 'Test',
227
+ tasks: [
228
+ parallel(task('Parent', {
229
+ subtasks: [
230
+ task('Child 1'),
231
+ task('Child 2'),
232
+ ],
233
+ })),
234
+ ],
235
+ });
236
+ const markdown = toMarkdown(project);
237
+ expect(markdown).toContain('- [ ] Parent');
238
+ expect(markdown).toContain(' - [ ] Child 1');
239
+ expect(markdown).toContain(' - [ ] Child 2');
240
+ });
241
+ it('should handle nested parallel and sequential groups', () => {
242
+ const project = createProject({
243
+ name: 'Test',
244
+ tasks: [
245
+ sequential(parallel(task('A'), task('B')), task('C')),
246
+ ],
247
+ });
248
+ const markdown = toMarkdown(project);
249
+ // Nested parallel should be unordered
250
+ expect(markdown).toContain('- [ ] A');
251
+ expect(markdown).toContain('- [ ] B');
252
+ // Following task should be sequential
253
+ expect(markdown).toContain('[ ] C');
254
+ });
255
+ it('should respect indentSize option', () => {
256
+ const project = createProject({
257
+ name: 'Test',
258
+ tasks: [
259
+ parallel(task('Parent', {
260
+ subtasks: [task('Child')],
261
+ })),
262
+ ],
263
+ });
264
+ const markdown = toMarkdown(project, { indentSize: 4 });
265
+ expect(markdown).toContain(' - [ ] Child');
266
+ });
267
+ });
268
+ describe('Round-trip conversion', () => {
269
+ it('should preserve basic structure through round-trip', () => {
270
+ const original = `# Project Name
271
+
272
+ - [ ] Task A
273
+ - [ ] Task B
274
+ - [ ] Task C
275
+ `;
276
+ const project = parseMarkdown(original);
277
+ const regenerated = toMarkdown(project);
278
+ expect(regenerated).toContain('# Project Name');
279
+ expect(regenerated).toContain('- [ ] Task A');
280
+ expect(regenerated).toContain('- [ ] Task B');
281
+ expect(regenerated).toContain('- [ ] Task C');
282
+ });
283
+ it('should preserve sequential structure through round-trip', () => {
284
+ const original = `# Sequential Project
285
+
286
+ 1. [ ] Step 1
287
+ 2. [ ] Step 2
288
+ 3. [ ] Step 3
289
+ `;
290
+ const project = parseMarkdown(original);
291
+ const regenerated = toMarkdown(project);
292
+ expect(regenerated).toContain('1. [ ] Step 1');
293
+ expect(regenerated).toContain('2. [ ] Step 2');
294
+ expect(regenerated).toContain('3. [ ] Step 3');
295
+ });
296
+ it('should preserve task status through round-trip', () => {
297
+ const original = `# Status Project
298
+
299
+ - [x] Completed
300
+ - [-] In progress
301
+ - [ ] Pending
302
+ `;
303
+ const project = parseMarkdown(original);
304
+ const regenerated = toMarkdown(project);
305
+ expect(regenerated).toContain('[x] Completed');
306
+ expect(regenerated).toContain('[-] In progress');
307
+ expect(regenerated).toContain('[ ] Pending');
308
+ });
309
+ });
310
+ describe('syncStatusFromMarkdown()', () => {
311
+ it('should update task statuses from markdown', () => {
312
+ const project = createProject({
313
+ name: 'Test',
314
+ tasks: [
315
+ parallel(task('Task A', { metadata: { _originalStatus: 'pending' } }), task('Task B', { metadata: { _originalStatus: 'pending' } })),
316
+ ],
317
+ });
318
+ const markdown = `# Test
319
+
320
+ - [x] Task A
321
+ - [-] Task B
322
+ `;
323
+ const updated = syncStatusFromMarkdown(project, markdown);
324
+ const tasks = updated.tasks[0].tasks;
325
+ expect(tasks[0].metadata?._originalStatus).toBe('completed');
326
+ expect(tasks[1].metadata?._originalStatus).toBe('in_progress');
327
+ });
328
+ it('should preserve existing status if task not in markdown', () => {
329
+ const project = createProject({
330
+ name: 'Test',
331
+ tasks: [
332
+ parallel(task('Existing Task', { metadata: { _originalStatus: 'in_progress' } })),
333
+ ],
334
+ });
335
+ const markdown = `# Test
336
+
337
+ - [ ] Different Task
338
+ `;
339
+ const updated = syncStatusFromMarkdown(project, markdown);
340
+ const tasks = updated.tasks[0].tasks;
341
+ expect(tasks[0].metadata?._originalStatus).toBe('in_progress');
342
+ });
343
+ it('should update nested subtask statuses', () => {
344
+ const project = createProject({
345
+ name: 'Test',
346
+ tasks: [
347
+ parallel(task('Parent', {
348
+ metadata: { _originalStatus: 'pending' },
349
+ subtasks: [
350
+ task('Child', { metadata: { _originalStatus: 'pending' } }),
351
+ ],
352
+ })),
353
+ ],
354
+ });
355
+ const markdown = `# Test
356
+
357
+ - [x] Parent
358
+ - [x] Child
359
+ `;
360
+ const updated = syncStatusFromMarkdown(project, markdown);
361
+ const parent = updated.tasks[0].tasks[0];
362
+ expect(parent.metadata?._originalStatus).toBe('completed');
363
+ expect(parent.subtasks[0].metadata?._originalStatus).toBe('completed');
364
+ });
365
+ it('should update the updatedAt timestamp', () => {
366
+ const project = createProject({
367
+ name: 'Test',
368
+ tasks: [],
369
+ });
370
+ const originalUpdatedAt = project.updatedAt;
371
+ const markdown = `# Test`;
372
+ // Small delay to ensure different timestamp
373
+ const updated = syncStatusFromMarkdown(project, markdown);
374
+ expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(originalUpdatedAt.getTime());
375
+ });
376
+ });
377
+ describe('Edge cases', () => {
378
+ it('should handle tasks with special characters', () => {
379
+ const markdown = `# Project
380
+
381
+ - [ ] Task with "quotes"
382
+ - [ ] Task with \`backticks\`
383
+ - [ ] Task with & ampersand
384
+ `;
385
+ const project = parseMarkdown(markdown);
386
+ const tasks = project.tasks[0].tasks;
387
+ expect(tasks[0].title).toBe('Task with "quotes"');
388
+ expect(tasks[1].title).toBe('Task with `backticks`');
389
+ expect(tasks[2].title).toBe('Task with & ampersand');
390
+ });
391
+ it('should handle deeply nested subtasks', () => {
392
+ const markdown = `# Project
393
+
394
+ - [ ] Level 1
395
+ - [ ] Level 2
396
+ - [ ] Level 3
397
+ - [ ] Level 4
398
+ `;
399
+ const project = parseMarkdown(markdown);
400
+ const level1 = project.tasks[0].tasks[0];
401
+ const level2 = level1.subtasks[0];
402
+ const level3 = level2.subtasks[0];
403
+ const level4 = level3.subtasks[0];
404
+ expect(level1.title).toBe('Level 1');
405
+ expect(level2.title).toBe('Level 2');
406
+ expect(level3.title).toBe('Level 3');
407
+ expect(level4.title).toBe('Level 4');
408
+ });
409
+ it('should handle mixed ordered and unordered in same section', () => {
410
+ const markdown = `# Project
411
+
412
+ - [ ] Unordered
413
+ 1. [ ] Ordered
414
+ - [ ] Unordered again
415
+ `;
416
+ const project = parseMarkdown(markdown);
417
+ // Should treat first set as parallel based on first task
418
+ expect(project.tasks.length).toBeGreaterThan(0);
419
+ });
420
+ it('should handle empty lines between tasks', () => {
421
+ const markdown = `# Project
422
+
423
+ - [ ] Task 1
424
+
425
+ - [ ] Task 2
426
+
427
+ - [ ] Task 3
428
+ `;
429
+ const project = parseMarkdown(markdown);
430
+ // Should still parse all tasks
431
+ const allTasks = [];
432
+ function collectTasks(tasks) {
433
+ for (const t of tasks) {
434
+ if (t.__type === 'task')
435
+ allTasks.push(t.title);
436
+ else if (t.tasks)
437
+ collectTasks(t.tasks);
438
+ }
439
+ }
440
+ collectTasks(project.tasks);
441
+ expect(allTasks).toContain('Task 1');
442
+ expect(allTasks).toContain('Task 2');
443
+ expect(allTasks).toContain('Task 3');
444
+ });
445
+ it('should handle Windows-style line endings', () => {
446
+ const markdown = '# Project\r\n\r\n- [ ] Task 1\r\n- [ ] Task 2\r\n';
447
+ const project = parseMarkdown(markdown);
448
+ expect(project.name).toBe('Project');
449
+ });
450
+ });
451
+ });