tests/store.test.js (7316 bytes)
1 import assert from 'node:assert/strict'; 2 import { spawn } from 'node:child_process'; 3 import fs from 'node:fs'; 4 import os from 'node:os'; 5 import path from 'node:path'; 6 import { beforeEach, test } from 'node:test'; 7 import { fileURLToPath } from 'node:url'; 8 import * as store from '../src/store.js'; 9 10 const BIN = fileURLToPath(new URL('../bin/ideamine.js', import.meta.url)); 11 12 beforeEach(() => { 13 process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-test-')); 14 }); 15 16 test('add, triage, and list ideas by lane and priority', () => { 17 store.addIdeas(['small win', 'big but slow', 'meh', 'later'], { project: '/p' }); 18 store.applyTriage([ 19 { id: 1, verdict: 'do', impact: 3, size: 'xs', model: 'haiku', title: 'Small win', why: 'cheap', brief: 'b' }, 20 { id: 2, verdict: 'do', impact: 5, size: 'xl', model: 'claude-opus-5', title: 'Big', why: 'w', brief: 'b' }, 21 { id: 3, verdict: 'skip', impact: 1, size: 'm', model: 'sonnet', title: 'Meh', why: 'no', brief: 'ignored' }, 22 ]); 23 const db = store.load(); 24 assert.deepEqual(store.listIdeas(db).map((i) => i.id), [1, 2, 4]); // do by value/effort, then inbox 25 assert.deepEqual(store.listIdeas(db, { filter: 'skip' }).map((i) => i.id), [3]); 26 assert.equal(store.findIdea(db, 2).triage.model, 'opus'); // full model ids normalize to aliases 27 assert.equal(store.findIdea(db, 3).triage.brief, ''); // skip verdicts carry no brief 28 assert.equal(store.pickNext(db, { project: '/p' }).id, 1); 29 assert.deepEqual(store.counts(db), { inbox: 1, do: 2, maybe: 0, skip: 1, doing: 0, done: 0, dropped: 0, open: 3 }); 30 }); 31 32 test('pickNext takes the best "do" idea, else the best "maybe", and never an idea from another lane', () => { 33 // Every combination of lanes, so that no lane can get into or out of the queue unseen. 34 const LANES = ['do', 'maybe', 'skip', 'inbox', 'doing', 'done', 'dropped']; 35 const VERDICTS = ['do', 'maybe', 'skip']; 36 const make = (lane, id) => ({ 37 id, 38 title: lane, 39 status: VERDICTS.includes(lane) ? 'triaged' : lane, 40 triage: lane === 'inbox' ? null : { verdict: VERDICTS.includes(lane) ? lane : 'do', impact: 3, size: 'm', model: 'sonnet' }, 41 }); 42 for (let mask = 0; mask < 1 << LANES.length; mask++) { 43 const present = LANES.filter((_, bit) => mask & (1 << bit)); 44 const db = { ideas: present.map((lane, i) => make(lane, i + 1)) }; 45 const want = present.includes('do') ? 'do' : present.includes('maybe') ? 'maybe' : null; 46 assert.equal(store.pickNext(db)?.title ?? null, want, `lanes: ${present.join(', ')}`); 47 } 48 49 // The verdict counts before the project: a "do" idea elsewhere beats a "maybe" idea here. 50 const db = { 51 ideas: [ 52 { id: 1, status: 'triaged', project: '/here', triage: { verdict: 'maybe', impact: 5, size: 'xs', model: 'haiku' } }, 53 { id: 2, status: 'triaged', project: '/there', triage: { verdict: 'do', impact: 1, size: 'xl', model: 'opus' } }, 54 ], 55 }; 56 assert.equal(store.pickNext(db, { project: '/here' }).id, 2); 57 }); 58 59 test('regression: after a triage of "0 do · 1 maybe · 1 skip", /ideas go still has an idea to build', () => { 60 store.addIdeas(['tes', 'redesign the AssistKey UI for a premium e-ink feel']); 61 store.applyTriage([ 62 { id: 1, verdict: 'skip', impact: 1, size: 'xs', model: 'haiku', title: "Clarify vague 'tes' idea", why: 'w', brief: '' }, 63 { id: 2, verdict: 'maybe', impact: 3, size: 'l', model: 'opus', title: 'Redesign AssistKey UI', why: 'w', brief: 'b' }, 64 ]); 65 assert.equal(store.pickNext(store.load()).id, 2); 66 }); 67 68 test('removeIdeas deletes ideas for good, all or nothing, and never reuses an id', () => { 69 store.addIdeas(['one', 'two', 'three']); 70 assert.throws(() => store.removeIdeas([1, 99]), /no idea #99/); 71 assert.equal(store.load().ideas.length, 3); // an unknown id deletes nothing 72 assert.deepEqual(store.removeIdeas(['#2', 3, '3']).map((i) => i.id), [2, 3]); 73 assert.deepEqual(store.load().ideas.map((i) => i.id), [1]); 74 assert.equal(store.addIdeas(['four'])[0].idea.id, 4); 75 }); 76 77 test('bad verdicts are reported per item, not fatal', () => { 78 store.addIdeas(['one']); 79 const res = store.applyTriage([{ id: 1, verdict: 'yes' }, { id: 99, verdict: 'do' }]); 80 assert.match(res[0].error, /verdict/); 81 assert.equal(res[1].error, 'no such idea'); 82 assert.equal(store.findIdea(store.load(), 1).status, 'inbox'); 83 }); 84 85 test('status changes, notes, model override, and reopen', () => { 86 store.addIdeas(['thing']); 87 store.updateIdea(1, { status: 'start', note: 'on it' }); 88 let idea = store.updateIdea('#1', { status: 'done', note: 'shipped' }); 89 assert.equal(idea.status, 'done'); 90 assert.ok(idea.closed); 91 assert.deepEqual(idea.notes.map((n) => n.text), ['on it', 'shipped']); 92 idea = store.updateIdea(1, { status: 'reopen', model: 'opus' }); 93 assert.equal(idea.status, 'triaged'); 94 assert.equal(idea.triage.model, 'opus'); 95 assert.equal(idea.closed, undefined); 96 assert.throws(() => store.updateIdea(1, { status: 'nope' }), /status must be/); 97 assert.throws(() => store.updateIdea(42, { note: 'x' }), /no idea #42/); 98 }); 99 100 test('the start time is recorded when work starts, for the dashboard timeline', async () => { 101 store.addIdeas(['thing']); 102 assert.equal(store.findIdea(store.load(), 1).started, undefined); 103 const first = store.updateIdea(1, { status: 'doing' }).started; 104 assert.ok(first); 105 assert.equal(store.updateIdea(1, { status: 'doing', note: 'still on it' }).started, first); // no new start 106 assert.equal(store.updateIdea(1, { status: 'done' }).started, first); 107 store.updateIdea(1, { status: 'reopen' }); 108 await new Promise((resolve) => setTimeout(resolve, 5)); 109 assert.ok(store.updateIdea(1, { status: 'start' }).started > first); // the latest start 110 }); 111 112 test('a corrupt archive is never overwritten', () => { 113 fs.mkdirSync(store.home(), { recursive: true }); 114 fs.writeFileSync(store.dbPath(), '{"ideas": [oops'); 115 assert.throws(() => store.addIdeas(['x']), /not valid JSON/); 116 assert.equal(fs.readFileSync(store.dbPath(), 'utf8'), '{"ideas": [oops'); 117 }); 118 119 test('a stale lock from a crashed writer is recovered', () => { 120 fs.mkdirSync(path.join(store.home(), '.lock'), { recursive: true }); 121 const old = new Date(Date.now() - 60_000); 122 fs.utimesSync(path.join(store.home(), '.lock'), old, old); 123 assert.equal(store.addIdeas(['after crash'])[0].idea.id, 1); 124 }); 125 126 test('concurrent writers from many processes never lose an idea', async () => { 127 const procs = 8; 128 const each = 6; 129 const run = (n) => 130 new Promise((resolve, reject) => { 131 // Each process appends its ideas through the CLI, all racing for the same archive. 132 const script = `for (let i = 0; i < ${each}; i++) require('node:child_process').execFileSync(process.execPath, [${JSON.stringify(BIN)}, 'add', 'proc ${n} idea ' + i]);`; 133 const child = spawn(process.execPath, ['-e', script], { env: process.env, stdio: ['ignore', 'ignore', 'pipe'] }); 134 let err = ''; 135 child.stderr.on('data', (d) => (err += d)); 136 child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(err)))); 137 }); 138 await Promise.all(Array.from({ length: procs }, (_, n) => run(n))); 139 const ids = store.load().ideas.map((i) => i.id).sort((a, b) => a - b); 140 assert.equal(ids.length, procs * each); 141 assert.deepEqual(ids, Array.from({ length: procs * each }, (_, i) => i + 1)); 142 });