deepflow 0.1.97 → 0.1.98
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.
- package/bin/plan-consolidator.js +330 -0
- package/bin/plan-consolidator.test.js +882 -0
- package/bin/wave-runner.js +74 -8
- package/bin/wave-runner.test.js +529 -2
- package/hooks/df-subagent-registry.js +8 -0
- package/hooks/df-subagent-registry.test.js +110 -3
- package/package.json +1 -1
- package/src/commands/df/execute.md +38 -7
- package/src/commands/df/plan.md +112 -114
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for bin/plan-consolidator.js — mechanical consolidation of mini-plans.
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* 1. Mini-plan parsing: task extraction, tags, files, blocked-by
|
|
6
|
+
* 2. T-id renumbering from local to global (sequential, no gaps)
|
|
7
|
+
* 3. Blocked-by reference remapping to global T-ids
|
|
8
|
+
* 4. Cross-spec file-conflict detection with [file-conflict: {filename}] annotations
|
|
9
|
+
* 5. Input mini-plan files are never modified (read-only)
|
|
10
|
+
* 6. Edge cases: single spec, empty plans, duplicate files, no tasks
|
|
11
|
+
* 7. CLI: --plans-dir argument parsing and stdout output
|
|
12
|
+
* 8. Output formatting: wave-runner-compatible markdown
|
|
13
|
+
*
|
|
14
|
+
* Uses Node.js built-in node:test to avoid adding dependencies.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
const { test, describe } = require('node:test');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
const os = require('node:os');
|
|
24
|
+
const { execFileSync, spawnSync } = require('node:child_process');
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
const CONSOLIDATOR_PATH = path.resolve(__dirname, 'plan-consolidator.js');
|
|
31
|
+
const CONSOLIDATOR_SRC = fs.readFileSync(CONSOLIDATOR_PATH, 'utf8');
|
|
32
|
+
|
|
33
|
+
function makeTmpDir() {
|
|
34
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'df-plan-consolidator-test-'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rmrf(dir) {
|
|
38
|
+
if (fs.existsSync(dir)) {
|
|
39
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Extract pure functions from plan-consolidator.js source for unit testing.
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
const extractedFns = (() => {
|
|
48
|
+
const modifiedSrc = CONSOLIDATOR_SRC
|
|
49
|
+
.replace(/^main\(\);?\s*$/m, '')
|
|
50
|
+
.replace(/^#!.*$/m, '');
|
|
51
|
+
|
|
52
|
+
const wrapped = `
|
|
53
|
+
${modifiedSrc}
|
|
54
|
+
return { parseArgs, parseMiniPlan, detectFileConflicts, consolidate, formatConsolidated };
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
const factory = new Function('require', 'process', '__dirname', '__filename', 'module', 'exports', wrapped);
|
|
58
|
+
return factory(require, process, __dirname, __filename, module, exports);
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
const { parseArgs, parseMiniPlan, detectFileConflicts, consolidate, formatConsolidated } = extractedFns;
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// CLI runner helper
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function runConsolidator(args = [], { cwd } = {}) {
|
|
68
|
+
const result = spawnSync(
|
|
69
|
+
process.execPath,
|
|
70
|
+
[CONSOLIDATOR_PATH, ...args],
|
|
71
|
+
{
|
|
72
|
+
cwd: cwd || os.tmpdir(),
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
return {
|
|
77
|
+
stdout: result.stdout || '',
|
|
78
|
+
stderr: result.stderr || '',
|
|
79
|
+
code: result.status ?? 1,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// 1. parseArgs
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('parseArgs', () => {
|
|
88
|
+
test('extracts --plans-dir value', () => {
|
|
89
|
+
const args = parseArgs(['node', 'plan-consolidator.js', '--plans-dir', '/tmp/plans']);
|
|
90
|
+
assert.equal(args.plansDir, '/tmp/plans');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('returns null when --plans-dir missing', () => {
|
|
94
|
+
const args = parseArgs(['node', 'plan-consolidator.js']);
|
|
95
|
+
assert.equal(args.plansDir, null);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('returns null when --plans-dir has no following value', () => {
|
|
99
|
+
const args = parseArgs(['node', 'plan-consolidator.js', '--plans-dir']);
|
|
100
|
+
assert.equal(args.plansDir, null);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// 2. parseMiniPlan
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
describe('parseMiniPlan', () => {
|
|
109
|
+
test('parses basic pending tasks', () => {
|
|
110
|
+
const text = `## Tasks
|
|
111
|
+
|
|
112
|
+
- [ ] **T1**: Set up project structure
|
|
113
|
+
- [ ] **T2**: Add routing layer
|
|
114
|
+
`;
|
|
115
|
+
const tasks = parseMiniPlan(text);
|
|
116
|
+
assert.equal(tasks.length, 2);
|
|
117
|
+
assert.equal(tasks[0].localId, 'T1');
|
|
118
|
+
assert.equal(tasks[0].num, 1);
|
|
119
|
+
assert.equal(tasks[0].description, 'Set up project structure');
|
|
120
|
+
assert.equal(tasks[1].localId, 'T2');
|
|
121
|
+
assert.equal(tasks[1].description, 'Add routing layer');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('parses tasks with tags', () => {
|
|
125
|
+
const text = '- [ ] **T1** [spike]: Explore API design\n';
|
|
126
|
+
const tasks = parseMiniPlan(text);
|
|
127
|
+
assert.equal(tasks.length, 1);
|
|
128
|
+
assert.equal(tasks[0].tags, '[spike]');
|
|
129
|
+
assert.equal(tasks[0].description, 'Explore API design');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('parses Files annotation', () => {
|
|
133
|
+
const text = `- [ ] **T1**: Do something
|
|
134
|
+
- Files: src/a.js, src/b.js
|
|
135
|
+
`;
|
|
136
|
+
const tasks = parseMiniPlan(text);
|
|
137
|
+
assert.equal(tasks.length, 1);
|
|
138
|
+
assert.deepEqual(tasks[0].files, ['src/a.js', 'src/b.js']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('parses Blocked by annotation', () => {
|
|
142
|
+
const text = `- [ ] **T1**: First task
|
|
143
|
+
- [ ] **T2**: Second task
|
|
144
|
+
- Blocked by: T1
|
|
145
|
+
`;
|
|
146
|
+
const tasks = parseMiniPlan(text);
|
|
147
|
+
assert.equal(tasks.length, 2);
|
|
148
|
+
assert.deepEqual(tasks[1].blockedBy, ['T1']);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('parses multiple blocked-by refs', () => {
|
|
152
|
+
const text = `- [ ] **T1**: A
|
|
153
|
+
- [ ] **T2**: B
|
|
154
|
+
- [ ] **T3**: C
|
|
155
|
+
- Blocked by: T1, T2
|
|
156
|
+
`;
|
|
157
|
+
const tasks = parseMiniPlan(text);
|
|
158
|
+
assert.deepEqual(tasks[2].blockedBy, ['T1', 'T2']);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('skips completed tasks', () => {
|
|
162
|
+
const text = `- [x] **T1**: Done task
|
|
163
|
+
- [ ] **T2**: Pending task
|
|
164
|
+
`;
|
|
165
|
+
const tasks = parseMiniPlan(text);
|
|
166
|
+
assert.equal(tasks.length, 1);
|
|
167
|
+
assert.equal(tasks[0].localId, 'T2');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('does not attach annotations from after a completed task to a previous pending task', () => {
|
|
171
|
+
const text = `- [ ] **T1**: First
|
|
172
|
+
- [x] **T2**: Completed
|
|
173
|
+
- Files: should-not-attach.js
|
|
174
|
+
- [ ] **T3**: Third
|
|
175
|
+
`;
|
|
176
|
+
const tasks = parseMiniPlan(text);
|
|
177
|
+
assert.equal(tasks.length, 2);
|
|
178
|
+
// Files line after completed T2 should not attach to T1
|
|
179
|
+
assert.deepEqual(tasks[0].files, []);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('returns empty array for text with no tasks', () => {
|
|
183
|
+
const tasks = parseMiniPlan('# Just a heading\n\nSome prose.\n');
|
|
184
|
+
assert.equal(tasks.length, 0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('returns empty array for empty string', () => {
|
|
188
|
+
const tasks = parseMiniPlan('');
|
|
189
|
+
assert.equal(tasks.length, 0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('handles task with no description', () => {
|
|
193
|
+
const text = '- [ ] **T1**:\n';
|
|
194
|
+
const tasks = parseMiniPlan(text);
|
|
195
|
+
assert.equal(tasks.length, 1);
|
|
196
|
+
assert.equal(tasks[0].description, '');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// 3. detectFileConflicts
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
describe('detectFileConflicts', () => {
|
|
205
|
+
test('detects files touched by multiple specs', () => {
|
|
206
|
+
const specEntries = [
|
|
207
|
+
{ specName: 'auth', tasks: [{ files: ['src/user.js', 'src/db.js'] }] },
|
|
208
|
+
{ specName: 'billing', tasks: [{ files: ['src/db.js', 'src/payment.js'] }] },
|
|
209
|
+
];
|
|
210
|
+
const conflicts = detectFileConflicts(specEntries);
|
|
211
|
+
assert.equal(conflicts.size, 1);
|
|
212
|
+
assert.ok(conflicts.has('src/db.js'));
|
|
213
|
+
const specs = conflicts.get('src/db.js');
|
|
214
|
+
assert.ok(specs.includes('auth'));
|
|
215
|
+
assert.ok(specs.includes('billing'));
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('returns empty map when no conflicts', () => {
|
|
219
|
+
const specEntries = [
|
|
220
|
+
{ specName: 'auth', tasks: [{ files: ['src/auth.js'] }] },
|
|
221
|
+
{ specName: 'billing', tasks: [{ files: ['src/billing.js'] }] },
|
|
222
|
+
];
|
|
223
|
+
const conflicts = detectFileConflicts(specEntries);
|
|
224
|
+
assert.equal(conflicts.size, 0);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('returns empty map for single spec', () => {
|
|
228
|
+
const specEntries = [
|
|
229
|
+
{ specName: 'auth', tasks: [{ files: ['src/a.js'] }, { files: ['src/a.js'] }] },
|
|
230
|
+
];
|
|
231
|
+
const conflicts = detectFileConflicts(specEntries);
|
|
232
|
+
assert.equal(conflicts.size, 0);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('returns empty map when no files declared', () => {
|
|
236
|
+
const specEntries = [
|
|
237
|
+
{ specName: 'auth', tasks: [{ files: [] }] },
|
|
238
|
+
{ specName: 'billing', tasks: [{ files: [] }] },
|
|
239
|
+
];
|
|
240
|
+
const conflicts = detectFileConflicts(specEntries);
|
|
241
|
+
assert.equal(conflicts.size, 0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('detects multiple conflicted files', () => {
|
|
245
|
+
const specEntries = [
|
|
246
|
+
{ specName: 'a', tasks: [{ files: ['shared.js', 'utils.js'] }] },
|
|
247
|
+
{ specName: 'b', tasks: [{ files: ['shared.js', 'utils.js'] }] },
|
|
248
|
+
{ specName: 'c', tasks: [{ files: ['shared.js'] }] },
|
|
249
|
+
];
|
|
250
|
+
const conflicts = detectFileConflicts(specEntries);
|
|
251
|
+
assert.equal(conflicts.size, 2);
|
|
252
|
+
assert.equal(conflicts.get('shared.js').length, 3);
|
|
253
|
+
assert.equal(conflicts.get('utils.js').length, 2);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---------------------------------------------------------------------------
|
|
258
|
+
// 4. consolidate — T-id renumbering and blocked-by remapping
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
describe('consolidate', () => {
|
|
262
|
+
test('renumbers T-ids globally in sequential order', () => {
|
|
263
|
+
const specEntries = [
|
|
264
|
+
{
|
|
265
|
+
specName: 'alpha',
|
|
266
|
+
tasks: [
|
|
267
|
+
{ localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: [], files: [] },
|
|
268
|
+
{ localId: 'T2', num: 2, description: 'A2', tags: '', blockedBy: [], files: [] },
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
specName: 'beta',
|
|
273
|
+
tasks: [
|
|
274
|
+
{ localId: 'T1', num: 1, description: 'B1', tags: '', blockedBy: [], files: [] },
|
|
275
|
+
{ localId: 'T2', num: 2, description: 'B2', tags: '', blockedBy: [], files: [] },
|
|
276
|
+
],
|
|
277
|
+
},
|
|
278
|
+
];
|
|
279
|
+
const result = consolidate(specEntries, new Map());
|
|
280
|
+
assert.equal(result.length, 4);
|
|
281
|
+
assert.equal(result[0].globalId, 'T1');
|
|
282
|
+
assert.equal(result[1].globalId, 'T2');
|
|
283
|
+
assert.equal(result[2].globalId, 'T3');
|
|
284
|
+
assert.equal(result[3].globalId, 'T4');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('remaps blocked-by references to global T-ids', () => {
|
|
288
|
+
const specEntries = [
|
|
289
|
+
{
|
|
290
|
+
specName: 'alpha',
|
|
291
|
+
tasks: [
|
|
292
|
+
{ localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: [], files: [] },
|
|
293
|
+
{ localId: 'T2', num: 2, description: 'A2', tags: '', blockedBy: ['T1'], files: [] },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
specName: 'beta',
|
|
298
|
+
tasks: [
|
|
299
|
+
{ localId: 'T1', num: 1, description: 'B1', tags: '', blockedBy: [], files: [] },
|
|
300
|
+
{ localId: 'T2', num: 2, description: 'B2', tags: '', blockedBy: ['T1'], files: [] },
|
|
301
|
+
],
|
|
302
|
+
},
|
|
303
|
+
];
|
|
304
|
+
const result = consolidate(specEntries, new Map());
|
|
305
|
+
// alpha T2 blocked by alpha T1 → global T2 blocked by T1
|
|
306
|
+
assert.deepEqual(result[1].blockedBy, ['T1']);
|
|
307
|
+
// beta T2 blocked by beta T1 → global T4 blocked by T3
|
|
308
|
+
assert.deepEqual(result[3].blockedBy, ['T3']);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('drops cross-spec blocked-by references that do not exist in local map', () => {
|
|
312
|
+
const specEntries = [
|
|
313
|
+
{
|
|
314
|
+
specName: 'alpha',
|
|
315
|
+
tasks: [
|
|
316
|
+
{ localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: ['T99'], files: [] },
|
|
317
|
+
],
|
|
318
|
+
},
|
|
319
|
+
];
|
|
320
|
+
const result = consolidate(specEntries, new Map());
|
|
321
|
+
assert.deepEqual(result[0].blockedBy, []);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test('adds file-conflict annotations to affected tasks', () => {
|
|
325
|
+
const specEntries = [
|
|
326
|
+
{
|
|
327
|
+
specName: 'alpha',
|
|
328
|
+
tasks: [
|
|
329
|
+
{ localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: [], files: ['shared.js'] },
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
];
|
|
333
|
+
const fileConflicts = new Map([['shared.js', ['alpha', 'beta']]]);
|
|
334
|
+
const result = consolidate(specEntries, fileConflicts);
|
|
335
|
+
assert.deepEqual(result[0].conflictAnnotations, ['[file-conflict: shared.js]']);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('no conflict annotations when file is not conflicted', () => {
|
|
339
|
+
const specEntries = [
|
|
340
|
+
{
|
|
341
|
+
specName: 'alpha',
|
|
342
|
+
tasks: [
|
|
343
|
+
{ localId: 'T1', num: 1, description: 'A1', tags: '', blockedBy: [], files: ['only-mine.js'] },
|
|
344
|
+
],
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
const result = consolidate(specEntries, new Map());
|
|
348
|
+
assert.deepEqual(result[0].conflictAnnotations, []);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('handles empty spec entries', () => {
|
|
352
|
+
const result = consolidate([], new Map());
|
|
353
|
+
assert.equal(result.length, 0);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('handles spec with no tasks', () => {
|
|
357
|
+
const specEntries = [{ specName: 'empty', tasks: [] }];
|
|
358
|
+
const result = consolidate(specEntries, new Map());
|
|
359
|
+
assert.equal(result.length, 0);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('preserves tags and specName on consolidated tasks', () => {
|
|
363
|
+
const specEntries = [
|
|
364
|
+
{
|
|
365
|
+
specName: 'auth',
|
|
366
|
+
tasks: [
|
|
367
|
+
{ localId: 'T1', num: 1, description: 'spike it', tags: '[spike]', blockedBy: [], files: [] },
|
|
368
|
+
],
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
const result = consolidate(specEntries, new Map());
|
|
372
|
+
assert.equal(result[0].tags, '[spike]');
|
|
373
|
+
assert.equal(result[0].specName, 'auth');
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// 5. formatConsolidated
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
describe('formatConsolidated', () => {
|
|
382
|
+
test('outputs empty state for no tasks', () => {
|
|
383
|
+
const output = formatConsolidated([]);
|
|
384
|
+
assert.ok(output.includes('(no tasks found)'));
|
|
385
|
+
assert.ok(output.startsWith('## Tasks'));
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test('groups tasks under spec headings', () => {
|
|
389
|
+
const tasks = [
|
|
390
|
+
{ globalId: 'T1', specName: 'auth', description: 'Login', tags: '', blockedBy: [], files: [], conflictAnnotations: [] },
|
|
391
|
+
{ globalId: 'T2', specName: 'auth', description: 'Logout', tags: '', blockedBy: ['T1'], files: [], conflictAnnotations: [] },
|
|
392
|
+
{ globalId: 'T3', specName: 'billing', description: 'Charge', tags: '', blockedBy: [], files: ['billing.js'], conflictAnnotations: [] },
|
|
393
|
+
];
|
|
394
|
+
const output = formatConsolidated(tasks);
|
|
395
|
+
assert.ok(output.includes('### auth'));
|
|
396
|
+
assert.ok(output.includes('### billing'));
|
|
397
|
+
assert.ok(output.includes('- [ ] **T1**: Login'));
|
|
398
|
+
assert.ok(output.includes('- [ ] **T2**: Logout'));
|
|
399
|
+
assert.ok(output.includes('Blocked by: T1'));
|
|
400
|
+
assert.ok(output.includes('Blocked by: none'));
|
|
401
|
+
assert.ok(output.includes('Files: billing.js'));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test('appends file-conflict annotations to description', () => {
|
|
405
|
+
const tasks = [
|
|
406
|
+
{
|
|
407
|
+
globalId: 'T1', specName: 'x', description: 'Do stuff', tags: '',
|
|
408
|
+
blockedBy: [], files: ['shared.js'], conflictAnnotations: ['[file-conflict: shared.js]'],
|
|
409
|
+
},
|
|
410
|
+
];
|
|
411
|
+
const output = formatConsolidated(tasks);
|
|
412
|
+
assert.ok(output.includes('[file-conflict: shared.js]'));
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test('includes tags in task header', () => {
|
|
416
|
+
const tasks = [
|
|
417
|
+
{ globalId: 'T1', specName: 'x', description: 'Spike it', tags: '[spike]', blockedBy: [], files: [], conflictAnnotations: [] },
|
|
418
|
+
];
|
|
419
|
+
const output = formatConsolidated(tasks);
|
|
420
|
+
assert.ok(output.includes('**T1** [spike]: Spike it'));
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
// 6. CLI integration tests
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
describe('CLI integration', () => {
|
|
429
|
+
test('exits 1 when --plans-dir is missing', () => {
|
|
430
|
+
const result = runConsolidator([]);
|
|
431
|
+
assert.equal(result.code, 1);
|
|
432
|
+
assert.ok(result.stderr.includes('--plans-dir'));
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('exits 1 when plans directory does not exist', () => {
|
|
436
|
+
const result = runConsolidator(['--plans-dir', '/tmp/nonexistent-df-test-dir-' + Date.now()]);
|
|
437
|
+
assert.equal(result.code, 1);
|
|
438
|
+
assert.ok(result.stderr.includes('not found'));
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test('outputs empty message for directory with no doing- files', () => {
|
|
442
|
+
const tmpDir = makeTmpDir();
|
|
443
|
+
try {
|
|
444
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
445
|
+
assert.equal(result.code, 0);
|
|
446
|
+
assert.ok(result.stdout.includes('no mini-plan files'));
|
|
447
|
+
} finally {
|
|
448
|
+
rmrf(tmpDir);
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test('consolidates a single mini-plan file', () => {
|
|
453
|
+
const tmpDir = makeTmpDir();
|
|
454
|
+
try {
|
|
455
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-auth.md'), `## Tasks
|
|
456
|
+
|
|
457
|
+
- [ ] **T1**: Set up auth module
|
|
458
|
+
- Files: src/auth.js
|
|
459
|
+
- [ ] **T2**: Add login endpoint
|
|
460
|
+
- Files: src/auth.js, src/routes.js
|
|
461
|
+
- Blocked by: T1
|
|
462
|
+
`);
|
|
463
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
464
|
+
assert.equal(result.code, 0);
|
|
465
|
+
assert.ok(result.stdout.includes('**T1**'));
|
|
466
|
+
assert.ok(result.stdout.includes('**T2**'));
|
|
467
|
+
assert.ok(result.stdout.includes('Blocked by: T1'));
|
|
468
|
+
assert.ok(result.stdout.includes('### auth'));
|
|
469
|
+
} finally {
|
|
470
|
+
rmrf(tmpDir);
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('consolidates multiple mini-plans with global renumbering', () => {
|
|
475
|
+
const tmpDir = makeTmpDir();
|
|
476
|
+
try {
|
|
477
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-auth.md'), `## Tasks
|
|
478
|
+
- [ ] **T1**: Auth task 1
|
|
479
|
+
- [ ] **T2**: Auth task 2
|
|
480
|
+
- Blocked by: T1
|
|
481
|
+
`);
|
|
482
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-billing.md'), `## Tasks
|
|
483
|
+
- [ ] **T1**: Billing task 1
|
|
484
|
+
- [ ] **T2**: Billing task 2
|
|
485
|
+
- Blocked by: T1
|
|
486
|
+
`);
|
|
487
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
488
|
+
assert.equal(result.code, 0);
|
|
489
|
+
|
|
490
|
+
// auth T1→T1, auth T2→T2, billing T1→T3, billing T2→T4
|
|
491
|
+
assert.ok(result.stdout.includes('**T1**: Auth task 1'));
|
|
492
|
+
assert.ok(result.stdout.includes('**T2**: Auth task 2'));
|
|
493
|
+
assert.ok(result.stdout.includes('**T3**: Billing task 1'));
|
|
494
|
+
assert.ok(result.stdout.includes('**T4**: Billing task 2'));
|
|
495
|
+
|
|
496
|
+
// Blocked-by remapping: billing T2 blocked by billing T1 → T4 blocked by T3
|
|
497
|
+
assert.ok(result.stdout.includes('Blocked by: T3'));
|
|
498
|
+
} finally {
|
|
499
|
+
rmrf(tmpDir);
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('detects cross-spec file conflicts and annotates tasks', () => {
|
|
504
|
+
const tmpDir = makeTmpDir();
|
|
505
|
+
try {
|
|
506
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-auth.md'), `## Tasks
|
|
507
|
+
- [ ] **T1**: Touch shared file
|
|
508
|
+
- Files: src/shared.js
|
|
509
|
+
`);
|
|
510
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-billing.md'), `## Tasks
|
|
511
|
+
- [ ] **T1**: Also touch shared file
|
|
512
|
+
- Files: src/shared.js
|
|
513
|
+
`);
|
|
514
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
515
|
+
assert.equal(result.code, 0);
|
|
516
|
+
assert.ok(result.stdout.includes('[file-conflict: src/shared.js]'));
|
|
517
|
+
// Conflict warning on stderr
|
|
518
|
+
assert.ok(result.stderr.includes('conflict'), 'stderr should mention conflict');
|
|
519
|
+
} finally {
|
|
520
|
+
rmrf(tmpDir);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('input mini-plan files are never modified', () => {
|
|
525
|
+
const tmpDir = makeTmpDir();
|
|
526
|
+
try {
|
|
527
|
+
const content = `## Tasks
|
|
528
|
+
- [ ] **T1**: Original task
|
|
529
|
+
- Files: src/a.js
|
|
530
|
+
- Blocked by: T99
|
|
531
|
+
`;
|
|
532
|
+
const filePath = path.join(tmpDir, 'doing-readonly.md');
|
|
533
|
+
fs.writeFileSync(filePath, content);
|
|
534
|
+
const mtimeBefore = fs.statSync(filePath).mtimeMs;
|
|
535
|
+
|
|
536
|
+
runConsolidator(['--plans-dir', tmpDir]);
|
|
537
|
+
|
|
538
|
+
const contentAfter = fs.readFileSync(filePath, 'utf8');
|
|
539
|
+
const mtimeAfter = fs.statSync(filePath).mtimeMs;
|
|
540
|
+
assert.equal(contentAfter, content, 'File content must not change');
|
|
541
|
+
assert.equal(mtimeAfter, mtimeBefore, 'File mtime must not change');
|
|
542
|
+
} finally {
|
|
543
|
+
rmrf(tmpDir);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
test('ignores non-doing files in plans directory', () => {
|
|
548
|
+
const tmpDir = makeTmpDir();
|
|
549
|
+
try {
|
|
550
|
+
fs.writeFileSync(path.join(tmpDir, 'done-old.md'), `- [ ] **T1**: Should be ignored\n`);
|
|
551
|
+
fs.writeFileSync(path.join(tmpDir, 'some-notes.md'), `- [ ] **T1**: Also ignored\n`);
|
|
552
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-active.md'), `- [ ] **T1**: Included\n`);
|
|
553
|
+
|
|
554
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
555
|
+
assert.equal(result.code, 0);
|
|
556
|
+
assert.ok(result.stdout.includes('**T1**: Included'));
|
|
557
|
+
assert.ok(!result.stdout.includes('Should be ignored'));
|
|
558
|
+
assert.ok(!result.stdout.includes('Also ignored'));
|
|
559
|
+
} finally {
|
|
560
|
+
rmrf(tmpDir);
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
test('processes files in alphabetical order for determinism', () => {
|
|
565
|
+
const tmpDir = makeTmpDir();
|
|
566
|
+
try {
|
|
567
|
+
// Write in reverse alphabetical order
|
|
568
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-zebra.md'), `- [ ] **T1**: Zebra task\n`);
|
|
569
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-alpha.md'), `- [ ] **T1**: Alpha task\n`);
|
|
570
|
+
|
|
571
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
572
|
+
assert.equal(result.code, 0);
|
|
573
|
+
// alpha sorts before zebra, so alpha T1 → T1, zebra T1 → T2
|
|
574
|
+
const t1Pos = result.stdout.indexOf('**T1**: Alpha task');
|
|
575
|
+
const t2Pos = result.stdout.indexOf('**T2**: Zebra task');
|
|
576
|
+
assert.ok(t1Pos >= 0, 'Alpha task should be T1');
|
|
577
|
+
assert.ok(t2Pos >= 0, 'Zebra task should be T2');
|
|
578
|
+
assert.ok(t1Pos < t2Pos, 'Alpha should appear before Zebra');
|
|
579
|
+
} finally {
|
|
580
|
+
rmrf(tmpDir);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('derives spec name correctly from hyphenated filename', () => {
|
|
585
|
+
const tmpDir = makeTmpDir();
|
|
586
|
+
try {
|
|
587
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-my-feature-name.md'), `- [ ] **T1**: Some task\n`);
|
|
588
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
589
|
+
assert.equal(result.code, 0);
|
|
590
|
+
assert.ok(result.stdout.includes('### my-feature-name'), 'spec name should preserve hyphens');
|
|
591
|
+
} finally {
|
|
592
|
+
rmrf(tmpDir);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
test('handles mini-plan with mixed completed and pending tasks', () => {
|
|
597
|
+
const tmpDir = makeTmpDir();
|
|
598
|
+
try {
|
|
599
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-mixed.md'), `## Tasks
|
|
600
|
+
|
|
601
|
+
- [x] **T1**: Already done
|
|
602
|
+
- Files: src/done.js
|
|
603
|
+
- [ ] **T2**: Still pending
|
|
604
|
+
- Files: src/pending.js
|
|
605
|
+
- Blocked by: T1
|
|
606
|
+
- [x] **T3**: Also done
|
|
607
|
+
- [ ] **T4**: Last pending
|
|
608
|
+
- Blocked by: T2
|
|
609
|
+
`);
|
|
610
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
611
|
+
assert.equal(result.code, 0);
|
|
612
|
+
// Only pending tasks included; T2 and T4 renumbered to T1 and T2
|
|
613
|
+
assert.ok(result.stdout.includes('**T1**: Still pending'), 'T2 becomes global T1');
|
|
614
|
+
assert.ok(result.stdout.includes('**T2**: Last pending'), 'T4 becomes global T2');
|
|
615
|
+
assert.ok(!result.stdout.includes('Already done'), 'completed tasks excluded');
|
|
616
|
+
assert.ok(!result.stdout.includes('Also done'), 'completed tasks excluded');
|
|
617
|
+
} finally {
|
|
618
|
+
rmrf(tmpDir);
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('blocked-by remapping skips completed task ids correctly', () => {
|
|
623
|
+
// T4 is blocked by T2 in the local plan; T1 and T3 are completed.
|
|
624
|
+
// After parsing, only T2→globalT1, T4→globalT2 remain. T4's dep on T2 → globalT1.
|
|
625
|
+
const tmpDir = makeTmpDir();
|
|
626
|
+
try {
|
|
627
|
+
fs.writeFileSync(path.join(tmpDir, 'doing-partial.md'), `## Tasks
|
|
628
|
+
|
|
629
|
+
- [x] **T1**: Completed first
|
|
630
|
+
- [ ] **T2**: Pending second
|
|
631
|
+
- [x] **T3**: Completed third
|
|
632
|
+
- [ ] **T4**: Pending fourth
|
|
633
|
+
- Blocked by: T2
|
|
634
|
+
`);
|
|
635
|
+
const result = runConsolidator(['--plans-dir', tmpDir]);
|
|
636
|
+
assert.equal(result.code, 0);
|
|
637
|
+
assert.ok(result.stdout.includes('**T1**: Pending second'));
|
|
638
|
+
assert.ok(result.stdout.includes('**T2**: Pending fourth'));
|
|
639
|
+
assert.ok(result.stdout.includes('Blocked by: T1'), 'T4 dep on T2 remapped to global T1');
|
|
640
|
+
} finally {
|
|
641
|
+
rmrf(tmpDir);
|
|
642
|
+
}
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
// 7. parseMiniPlan — additional edge cases
|
|
648
|
+
// ---------------------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
describe('parseMiniPlan — edge cases', () => {
|
|
651
|
+
test('parses task with large T-number (T100)', () => {
|
|
652
|
+
const text = '- [ ] **T100**: Big numbered task\n';
|
|
653
|
+
const tasks = parseMiniPlan(text);
|
|
654
|
+
assert.equal(tasks.length, 1);
|
|
655
|
+
assert.equal(tasks[0].localId, 'T100');
|
|
656
|
+
assert.equal(tasks[0].num, 100);
|
|
657
|
+
assert.equal(tasks[0].description, 'Big numbered task');
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
test('parses task with both tags and files+blocked-by annotations', () => {
|
|
661
|
+
const text = `- [ ] **T1** [spike]: Explore caching
|
|
662
|
+
- Files: src/cache.js, src/utils.js
|
|
663
|
+
- Blocked by: T0
|
|
664
|
+
`;
|
|
665
|
+
const tasks = parseMiniPlan(text);
|
|
666
|
+
assert.equal(tasks.length, 1);
|
|
667
|
+
assert.equal(tasks[0].tags, '[spike]');
|
|
668
|
+
assert.equal(tasks[0].description, 'Explore caching');
|
|
669
|
+
assert.deepEqual(tasks[0].files, ['src/cache.js', 'src/utils.js']);
|
|
670
|
+
assert.deepEqual(tasks[0].blockedBy, ['T0']);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test('File: singular form is also parsed', () => {
|
|
674
|
+
const text = `- [ ] **T1**: Single file task
|
|
675
|
+
- File: src/only.js
|
|
676
|
+
`;
|
|
677
|
+
const tasks = parseMiniPlan(text);
|
|
678
|
+
assert.equal(tasks.length, 1);
|
|
679
|
+
assert.deepEqual(tasks[0].files, ['src/only.js']);
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test('ignores whitespace-only file entries in files list', () => {
|
|
683
|
+
// If someone writes "Files: src/a.js, , src/b.js" — empty segments filtered out
|
|
684
|
+
const text = `- [ ] **T1**: Task
|
|
685
|
+
- Files: src/a.js, , src/b.js
|
|
686
|
+
`;
|
|
687
|
+
const tasks = parseMiniPlan(text);
|
|
688
|
+
assert.equal(tasks.length, 1);
|
|
689
|
+
// filter(Boolean) removes empty strings after trim
|
|
690
|
+
assert.ok(!tasks[0].files.includes(''), 'no empty string in files');
|
|
691
|
+
assert.ok(tasks[0].files.includes('src/a.js'));
|
|
692
|
+
assert.ok(tasks[0].files.includes('src/b.js'));
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test('multiple pending tasks with non-contiguous numbers', () => {
|
|
696
|
+
// Mini-plan might have T1, T3, T5 if someone manually edited it
|
|
697
|
+
const text = `- [ ] **T1**: First
|
|
698
|
+
- [ ] **T3**: Third (T2 was removed)
|
|
699
|
+
- [ ] **T5**: Fifth
|
|
700
|
+
`;
|
|
701
|
+
const tasks = parseMiniPlan(text);
|
|
702
|
+
assert.equal(tasks.length, 3);
|
|
703
|
+
assert.equal(tasks[0].localId, 'T1');
|
|
704
|
+
assert.equal(tasks[1].localId, 'T3');
|
|
705
|
+
assert.equal(tasks[2].localId, 'T5');
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test('malformed blocked-by line with no valid T-refs is parsed as empty', () => {
|
|
709
|
+
const text = `- [ ] **T1**: Task
|
|
710
|
+
- Blocked by: none
|
|
711
|
+
`;
|
|
712
|
+
const tasks = parseMiniPlan(text);
|
|
713
|
+
assert.equal(tasks.length, 1);
|
|
714
|
+
// "none" does not match /^T\d+$/ so blockedBy stays empty
|
|
715
|
+
assert.deepEqual(tasks[0].blockedBy, []);
|
|
716
|
+
});
|
|
717
|
+
|
|
718
|
+
test('case-insensitive blocked by', () => {
|
|
719
|
+
const text = `- [ ] **T1**: A
|
|
720
|
+
- [ ] **T2**: B
|
|
721
|
+
- blocked by: T1
|
|
722
|
+
`;
|
|
723
|
+
const tasks = parseMiniPlan(text);
|
|
724
|
+
assert.deepEqual(tasks[1].blockedBy, ['T1']);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test('case-insensitive completed task marker [X]', () => {
|
|
728
|
+
const text = `- [X] **T1**: Done uppercase X
|
|
729
|
+
- [ ] **T2**: Pending
|
|
730
|
+
`;
|
|
731
|
+
const tasks = parseMiniPlan(text);
|
|
732
|
+
assert.equal(tasks.length, 1);
|
|
733
|
+
assert.equal(tasks[0].localId, 'T2');
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------
|
|
738
|
+
// 8. consolidate — additional edge cases
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
|
|
741
|
+
describe('consolidate — additional edge cases', () => {
|
|
742
|
+
test('renumbers large task set with correct global offset', () => {
|
|
743
|
+
// First spec has T1..T10, second spec has T1..T5
|
|
744
|
+
const firstSpec = {
|
|
745
|
+
specName: 'big',
|
|
746
|
+
tasks: Array.from({ length: 10 }, (_, i) => ({
|
|
747
|
+
localId: `T${i + 1}`, num: i + 1,
|
|
748
|
+
description: `Task ${i + 1}`, tags: '', blockedBy: [], files: [],
|
|
749
|
+
})),
|
|
750
|
+
};
|
|
751
|
+
const secondSpec = {
|
|
752
|
+
specName: 'small',
|
|
753
|
+
tasks: Array.from({ length: 5 }, (_, i) => ({
|
|
754
|
+
localId: `T${i + 1}`, num: i + 1,
|
|
755
|
+
description: `Small ${i + 1}`, tags: '', blockedBy: [], files: [],
|
|
756
|
+
})),
|
|
757
|
+
};
|
|
758
|
+
const result = consolidate([firstSpec, secondSpec], new Map());
|
|
759
|
+
assert.equal(result.length, 15);
|
|
760
|
+
assert.equal(result[9].globalId, 'T10'); // last of first spec
|
|
761
|
+
assert.equal(result[10].globalId, 'T11'); // first of second spec
|
|
762
|
+
assert.equal(result[14].globalId, 'T15'); // last of second spec
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('task with multiple files where only some conflict gets all conflict annotations', () => {
|
|
766
|
+
const specEntries = [
|
|
767
|
+
{
|
|
768
|
+
specName: 'alpha',
|
|
769
|
+
tasks: [{
|
|
770
|
+
localId: 'T1', num: 1, description: 'Mixed files', tags: '',
|
|
771
|
+
blockedBy: [], files: ['shared.js', 'mine-only.js', 'also-shared.js'],
|
|
772
|
+
}],
|
|
773
|
+
},
|
|
774
|
+
];
|
|
775
|
+
const fileConflicts = new Map([
|
|
776
|
+
['shared.js', ['alpha', 'beta']],
|
|
777
|
+
['also-shared.js', ['alpha', 'gamma']],
|
|
778
|
+
]);
|
|
779
|
+
const result = consolidate(specEntries, fileConflicts);
|
|
780
|
+
assert.equal(result[0].conflictAnnotations.length, 2);
|
|
781
|
+
assert.ok(result[0].conflictAnnotations.includes('[file-conflict: shared.js]'));
|
|
782
|
+
assert.ok(result[0].conflictAnnotations.includes('[file-conflict: also-shared.js]'));
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test('chain-only: blocked-by ref to a non-local T-id is dropped silently', () => {
|
|
786
|
+
// T2 tries to block on T5 which doesn't exist in this spec
|
|
787
|
+
const specEntries = [
|
|
788
|
+
{
|
|
789
|
+
specName: 'solo',
|
|
790
|
+
tasks: [
|
|
791
|
+
{ localId: 'T1', num: 1, description: 'A', tags: '', blockedBy: [], files: [] },
|
|
792
|
+
{ localId: 'T2', num: 2, description: 'B', tags: '', blockedBy: ['T1', 'T5'], files: [] },
|
|
793
|
+
],
|
|
794
|
+
},
|
|
795
|
+
];
|
|
796
|
+
const result = consolidate(specEntries, new Map());
|
|
797
|
+
// T5 doesn't exist in this spec — should be silently dropped
|
|
798
|
+
assert.deepEqual(result[1].blockedBy, ['T1']);
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
test('spec with all completed tasks contributes nothing to consolidated output', () => {
|
|
802
|
+
// parseMiniPlan skips completed tasks; simulate a spec that had all tasks done
|
|
803
|
+
const specEntries = [
|
|
804
|
+
{ specName: 'done-spec', tasks: [] },
|
|
805
|
+
{
|
|
806
|
+
specName: 'active-spec',
|
|
807
|
+
tasks: [{ localId: 'T1', num: 1, description: 'Active', tags: '', blockedBy: [], files: [] }],
|
|
808
|
+
},
|
|
809
|
+
];
|
|
810
|
+
const result = consolidate(specEntries, new Map());
|
|
811
|
+
assert.equal(result.length, 1);
|
|
812
|
+
assert.equal(result[0].globalId, 'T1');
|
|
813
|
+
assert.equal(result[0].specName, 'active-spec');
|
|
814
|
+
});
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
// ---------------------------------------------------------------------------
|
|
818
|
+
// 9. formatConsolidated — additional edge cases
|
|
819
|
+
// ---------------------------------------------------------------------------
|
|
820
|
+
|
|
821
|
+
describe('formatConsolidated — additional edge cases', () => {
|
|
822
|
+
test('task with both tags and conflict annotations renders correctly', () => {
|
|
823
|
+
const tasks = [
|
|
824
|
+
{
|
|
825
|
+
globalId: 'T1', specName: 'x', description: 'Risky spike',
|
|
826
|
+
tags: '[spike]', blockedBy: [], files: ['shared.js'],
|
|
827
|
+
conflictAnnotations: ['[file-conflict: shared.js]'],
|
|
828
|
+
},
|
|
829
|
+
];
|
|
830
|
+
const output = formatConsolidated(tasks);
|
|
831
|
+
assert.ok(output.includes('**T1** [spike]: Risky spike [file-conflict: shared.js]'));
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test('task with no description but has conflict annotation', () => {
|
|
835
|
+
const tasks = [
|
|
836
|
+
{
|
|
837
|
+
globalId: 'T1', specName: 'x', description: '',
|
|
838
|
+
tags: '', blockedBy: [], files: ['shared.js'],
|
|
839
|
+
conflictAnnotations: ['[file-conflict: shared.js]'],
|
|
840
|
+
},
|
|
841
|
+
];
|
|
842
|
+
const output = formatConsolidated(tasks);
|
|
843
|
+
// descPart = ('' + ' [file-conflict: shared.js]').trim() = '[file-conflict: shared.js]'
|
|
844
|
+
assert.ok(output.includes(': [file-conflict: shared.js]'));
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
test('multiple specs produce separate section headings each time spec changes', () => {
|
|
848
|
+
const tasks = [
|
|
849
|
+
{ globalId: 'T1', specName: 'alpha', description: 'A1', tags: '', blockedBy: [], files: [], conflictAnnotations: [] },
|
|
850
|
+
{ globalId: 'T2', specName: 'beta', description: 'B1', tags: '', blockedBy: [], files: [], conflictAnnotations: [] },
|
|
851
|
+
{ globalId: 'T3', specName: 'alpha', description: 'A2', tags: '', blockedBy: [], files: [], conflictAnnotations: [] },
|
|
852
|
+
];
|
|
853
|
+
const output = formatConsolidated(tasks);
|
|
854
|
+
// alpha appears twice (tasks interleaved) — two ### alpha headings
|
|
855
|
+
const headingMatches = output.match(/### alpha/g);
|
|
856
|
+
assert.equal(headingMatches.length, 2, 'alpha heading appears twice when tasks interleave');
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
test('multiple files are joined with comma-space in output', () => {
|
|
860
|
+
const tasks = [
|
|
861
|
+
{
|
|
862
|
+
globalId: 'T1', specName: 'x', description: 'Multi-file',
|
|
863
|
+
tags: '', blockedBy: [], files: ['a.js', 'b.js', 'c.js'],
|
|
864
|
+
conflictAnnotations: [],
|
|
865
|
+
},
|
|
866
|
+
];
|
|
867
|
+
const output = formatConsolidated(tasks);
|
|
868
|
+
assert.ok(output.includes('Files: a.js, b.js, c.js'));
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test('multiple blocked-by refs are joined with comma-space', () => {
|
|
872
|
+
const tasks = [
|
|
873
|
+
{
|
|
874
|
+
globalId: 'T3', specName: 'x', description: 'Multi-dep',
|
|
875
|
+
tags: '', blockedBy: ['T1', 'T2'], files: [],
|
|
876
|
+
conflictAnnotations: [],
|
|
877
|
+
},
|
|
878
|
+
];
|
|
879
|
+
const output = formatConsolidated(tasks);
|
|
880
|
+
assert.ok(output.includes('Blocked by: T1, T2'));
|
|
881
|
+
});
|
|
882
|
+
});
|