diff --git a/checks/progress.mjs b/checks/progress.mjs index 269efd3..e33f9d9 100644 --- a/checks/progress.mjs +++ b/checks/progress.mjs @@ -274,8 +274,13 @@ export function derive({ scopeItems, specs: allSpecs }) { let state = 'todo'; if (mine.some((s) => s.status === SPEC_DONE)) state = 'done'; else if (mine.length) state = 'doing'; - if (mine.length > 1) { - warnings.push({ kind: 'doubleClaim', title: item.title, specs: mine.map((s) => s.file) }); + // Only plans still in flight can be working on it at once. A finished spec has shipped its + // part and a later spec supersedes that claim, which is ordinary history: a scope item worth + // two rounds of work carries two specs forever, and warning about it every run teaches the + // reader to skim the heads-up. Two unfinished plans on one item is the real clash. + const together = mine.filter((s) => s.status !== SPEC_DONE); + if (together.length > 1) { + warnings.push({ kind: 'doubleClaim', title: item.title, specs: together.map((s) => s.file) }); } return { ...item, state, specs: mine.map((s) => s.file) }; }); diff --git a/checks/progress.test.mjs b/checks/progress.test.mjs index 6c59eaa..eb9373e 100644 --- a/checks/progress.test.mjs +++ b/checks/progress.test.mjs @@ -124,16 +124,40 @@ test('a spec pointing at an unknown scope item is surfaced, not swallowed', () = assert.deepEqual(p.warnings, [{ kind: 'unknownItem', spec: 'ghost' }]); }); -test('two specs claiming one scope item: the furthest wins and the clash is reported', () => { +test('two unfinished specs claiming one scope item: the furthest wins and the clash is reported', () => { const p = derive({ scopeItems: items, specs: [ { file: 'a', status: 'building', traces: ['SC-1'] }, + { file: 'b', status: 'approved', traces: ['SC-1'] }, + ], + }); + assert.equal(p.items[0].state, 'doing'); + assert.deepEqual(p.warnings, [{ kind: 'doubleClaim', title: items[0].title, specs: ['a', 'b'] }]); +}); + +test('a later spec supersedes a finished claim: the item still counts done, and nobody is warned', () => { + const p = derive({ + scopeItems: items, + specs: [ + { file: 'shipped-it-first', status: 'done', traces: ['SC-1'] }, + { file: 'doing-it-again', status: 'building', traces: ['SC-1'] }, + ], + }); + assert.equal(p.items[0].state, 'done'); + assert.deepEqual(p.warnings, []); +}); + +test('two finished specs on one scope item are history, not a clash', () => { + const p = derive({ + scopeItems: items, + specs: [ + { file: 'a', status: 'done', traces: ['SC-1'] }, { file: 'b', status: 'done', traces: ['SC-1'] }, ], }); assert.equal(p.items[0].state, 'done'); - assert.deepEqual(p.warnings, [{ kind: 'doubleClaim', title: items[0].title, specs: ['a', 'b'] }]); + assert.deepEqual(p.warnings, []); }); test('no scope items means not defined, never a zero count', () => { @@ -167,7 +191,7 @@ test('warnings speak the project language and name no internal identifiers', () const clash = derive({ scopeItems: items, - specs: [{ file: 'a', status: 'building', traces: ['SC-1'] }, { file: 'b', status: 'done', traces: ['SC-1'] }], + specs: [{ file: 'a', status: 'building', traces: ['SC-1'] }, { file: 'b', status: 'draft', traces: ['SC-1'] }], }); const en = renderFull(project({ lang: 'en' }), clash); assert.match(en, /is being worked on from 2 plans at once \(a, b\)/);