|
| 1 | +/*--------------------------------------------------------------------------------------------- |
| 2 | + * sessionMemory — the cross-session memory store — run: node test/sessionMemory.test.js |
| 3 | + * |
| 4 | + * The plain-JSONL substrate for project memory (docs/levelcode-sessions-memory.md). Pure fs/path, so it |
| 5 | + * unit-tests against a temp dir: a journal entry derives deterministically from a session's index row |
| 6 | + * (sourced + dated), append/read roundtrips and tolerates corruption, and latestBySession collapses the |
| 7 | + * append-only log to one current outcome per session, newest-first. |
| 8 | + *--------------------------------------------------------------------------------------------*/ |
| 9 | +// @ts-check |
| 10 | +'use strict'; |
| 11 | + |
| 12 | +const assert = require('assert'); |
| 13 | +const fs = require('fs'); |
| 14 | +const os = require('os'); |
| 15 | +const path = require('path'); |
| 16 | +const M = require('../sessionMemory'); |
| 17 | + |
| 18 | +let n = 0; |
| 19 | +function test(name, fn) { fn(); n++; console.log(' ok - ' + name); } |
| 20 | + |
| 21 | +const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'lc-mem-')); |
| 22 | +process.on('exit', () => { try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best effort */ } }); |
| 23 | +let seq = 0; |
| 24 | +const freshRoot = () => path.join(tmpRoot, 'r' + (seq++)); |
| 25 | +const slug = 'proj'; |
| 26 | + |
| 27 | +test('OUTCOME: a journal entry derives from a session index row — deterministic, sourced + dated', () => { |
| 28 | + const derived = { id: 's1', title: 'Tidy the CHANGELOG for v1.0.4', updatedAt: 't-up', createdAt: 't-cr', |
| 29 | + turns: 6, state: 'done', pinned: true, filesEdited: ['RELEASE-NOTES.md', 'a', 'b', 'c', 'd', 'e', 'f', 'g'] }; |
| 30 | + const e = M.outcomeEntry(derived, 't-seal'); |
| 31 | + assert.strictEqual(e.id, 's1', 'source_session (provenance link back to the full JSONL)'); |
| 32 | + assert.strictEqual(e.at, 't-seal', 'learned_at = the seal time when given'); |
| 33 | + assert.strictEqual(e.title, 'Tidy the CHANGELOG for v1.0.4'); |
| 34 | + assert.strictEqual(e.summary, e.title, 'v1 outcome summary = the goal headline (model refines later)'); |
| 35 | + assert.strictEqual(e.turns, 6); |
| 36 | + assert.strictEqual(e.state, 'done'); |
| 37 | + assert.strictEqual(e.pinned, true); |
| 38 | + assert.strictEqual(e.files.length, 6, 'files capped at 6'); |
| 39 | + assert.strictEqual(e.files[0], 'RELEASE-NOTES.md'); |
| 40 | + assert.strictEqual(M.outcomeEntry(derived).at, 't-up', 'falls back to updatedAt with no seal time'); |
| 41 | +}); |
| 42 | + |
| 43 | +test('JOURNAL: append then read roundtrips oldest-first, creating memory/ on first write', () => { |
| 44 | + const root = freshRoot(); |
| 45 | + assert.deepStrictEqual(M.readJournal(root, slug), [], 'empty before any write'); |
| 46 | + M.appendJournal(root, slug, M.outcomeEntry({ id: 'a', title: 'one', updatedAt: 't1', turns: 1, state: 'done', filesEdited: [] })); |
| 47 | + M.appendJournal(root, slug, M.outcomeEntry({ id: 'b', title: 'two', updatedAt: 't2', turns: 2, state: 'done', filesEdited: ['x'] })); |
| 48 | + const j = M.readJournal(root, slug); |
| 49 | + assert.strictEqual(j.length, 2); |
| 50 | + assert.deepStrictEqual(j.map((e) => e.id), ['a', 'b'], 'append order (oldest-first)'); |
| 51 | + assert.ok(fs.existsSync(M.journalFile(root, slug)), 'journal.jsonl exists under memory/'); |
| 52 | +}); |
| 53 | + |
| 54 | +test('JOURNAL: read tolerates blank + corrupt lines (keeps the good ones)', () => { |
| 55 | + const root = freshRoot(); |
| 56 | + fs.mkdirSync(M.memoryDir(root, slug), { recursive: true }); |
| 57 | + fs.writeFileSync(M.journalFile(root, slug), |
| 58 | + JSON.stringify({ v: 1, id: 'a', at: 't1' }) + '\n\n{ not json\n' + JSON.stringify({ v: 1, id: 'b', at: 't2' }) + '\n'); |
| 59 | + assert.deepStrictEqual(M.readJournal(root, slug).map((e) => e.id), ['a', 'b'], 'blank + garbage skipped'); |
| 60 | +}); |
| 61 | + |
| 62 | +test('LATEST: one outcome per session — the latest supersedes, newest-first', () => { |
| 63 | + const entries = [ |
| 64 | + { id: 'a', at: '2026-01-01', title: 'a-old' }, |
| 65 | + { id: 'b', at: '2026-03-01', title: 'b' }, |
| 66 | + { id: 'a', at: '2026-02-01', title: 'a-new' } // 'a' resumed + re-sealed → later line wins |
| 67 | + ]; |
| 68 | + const latest = M.latestBySession(entries); |
| 69 | + assert.deepStrictEqual(latest.map((e) => e.id), ['b', 'a'], 'collapsed to one per id, newest-first by date'); |
| 70 | + assert.strictEqual(latest.find((e) => e.id === 'a').title, 'a-new', 'the later line supersedes the earlier'); |
| 71 | +}); |
| 72 | + |
| 73 | +console.log('sessionMemory: ' + n + ' tests passed'); |
0 commit comments