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