From 174d94932e6462913520fae12bdb29d6f24b82ed Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sat, 28 Mar 2026 23:05:11 -0700 Subject: [PATCH] =?UTF-8?q?test:=20learnings=20bin=20script=20unit=20tests?= =?UTF-8?q?=20=E2=80=94=2013=20tests,=20free?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests gstack-learnings-log (valid/invalid JSON, timestamp injection, append-only) and gstack-learnings-search (dedup, type/query/limit filters, confidence decay, user-stated no-decay, malformed JSONL skip). Co-Authored-By: Claude Opus 4.6 (1M context) --- test/learnings.test.ts | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 test/learnings.test.ts diff --git a/test/learnings.test.ts b/test/learnings.test.ts new file mode 100644 index 00000000..b3129b5f --- /dev/null +++ b/test/learnings.test.ts @@ -0,0 +1,193 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin'); + +let tmpDir: string; +let slugDir: string; +let learningsFile: string; + +function runLog(input: string, opts: { expectFail?: boolean } = {}): { stdout: string; exitCode: number } { + const execOpts: ExecSyncOptionsWithStringEncoding = { + cwd: ROOT, + env: { ...process.env, GSTACK_HOME: tmpDir }, + encoding: 'utf-8', + timeout: 15000, + }; + try { + const stdout = execSync(`${BIN}/gstack-learnings-log '${input.replace(/'/g, "'\\''")}'`, execOpts).trim(); + return { stdout, exitCode: 0 }; + } catch (e: any) { + if (opts.expectFail) { + return { stdout: e.stderr?.toString() || '', exitCode: e.status || 1 }; + } + throw e; + } +} + +function runSearch(args: string = ''): string { + const execOpts: ExecSyncOptionsWithStringEncoding = { + cwd: ROOT, + env: { ...process.env, GSTACK_HOME: tmpDir }, + encoding: 'utf-8', + timeout: 15000, + }; + try { + return execSync(`${BIN}/gstack-learnings-search ${args}`, execOpts).trim(); + } catch { + return ''; + } +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-learn-')); + slugDir = path.join(tmpDir, 'projects'); + fs.mkdirSync(slugDir, { recursive: true }); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function findLearningsFile(): string | null { + const projectDirs = fs.readdirSync(slugDir); + if (projectDirs.length === 0) return null; + const f = path.join(slugDir, projectDirs[0], 'learnings.jsonl'); + return fs.existsSync(f) ? f : null; +} + +describe('gstack-learnings-log', () => { + test('appends valid JSON to learnings.jsonl', () => { + const input = '{"skill":"review","type":"pattern","key":"test-key","insight":"test insight","confidence":8,"source":"observed"}'; + const result = runLog(input); + expect(result.exitCode).toBe(0); + + const f = findLearningsFile(); + expect(f).not.toBeNull(); + const content = fs.readFileSync(f!, 'utf-8').trim(); + const parsed = JSON.parse(content); + expect(parsed.skill).toBe('review'); + expect(parsed.key).toBe('test-key'); + expect(parsed.confidence).toBe(8); + }); + + test('auto-injects timestamp when ts is missing', () => { + const input = '{"skill":"review","type":"pattern","key":"ts-test","insight":"test","confidence":5,"source":"observed"}'; + runLog(input); + + const f = findLearningsFile(); + expect(f).not.toBeNull(); + const parsed = JSON.parse(fs.readFileSync(f!, 'utf-8').trim()); + expect(parsed.ts).toBeDefined(); + expect(new Date(parsed.ts).getTime()).toBeGreaterThan(0); + }); + + test('rejects non-JSON input with non-zero exit code', () => { + const result = runLog('not json at all', { expectFail: true }); + expect(result.exitCode).not.toBe(0); + }); + + test('append-only: duplicate keys create multiple entries', () => { + const input1 = '{"skill":"review","type":"pattern","key":"dup-key","insight":"first version","confidence":6,"source":"observed"}'; + const input2 = '{"skill":"review","type":"pattern","key":"dup-key","insight":"second version","confidence":8,"source":"observed"}'; + runLog(input1); + runLog(input2); + + const f = findLearningsFile(); + expect(f).not.toBeNull(); + const lines = fs.readFileSync(f!, 'utf-8').trim().split('\n'); + expect(lines.length).toBe(2); + }); +}); + +describe('gstack-learnings-search', () => { + test('returns empty and exits 0 when no learnings file exists', () => { + const output = runSearch(); + expect(output).toBe(''); + }); + + test('returns formatted output when learnings exist', () => { + runLog('{"skill":"review","type":"pattern","key":"test-search","insight":"search test insight","confidence":7,"source":"observed"}'); + const output = runSearch(); + expect(output).toContain('LEARNINGS:'); + expect(output).toContain('test-search'); + expect(output).toContain('search test insight'); + }); + + test('deduplicates entries by key+type (latest wins)', () => { + const old = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'old version', confidence: 5, source: 'observed', ts: '2026-01-01T00:00:00Z' }); + const newer = JSON.stringify({ skill: 'review', type: 'pattern', key: 'dedup-test', insight: 'new version', confidence: 8, source: 'observed', ts: '2026-03-28T00:00:00Z' }); + runLog(old); + runLog(newer); + + const output = runSearch(); + expect(output).toContain('new version'); + expect(output).not.toContain('old version'); + expect(output).toContain('1 loaded'); + }); + + test('filters by --type', () => { + runLog('{"skill":"review","type":"pattern","key":"p1","insight":"a pattern","confidence":7,"source":"observed"}'); + runLog('{"skill":"review","type":"pitfall","key":"p2","insight":"a pitfall","confidence":7,"source":"observed"}'); + + const patternOnly = runSearch('--type pattern'); + expect(patternOnly).toContain('p1'); + expect(patternOnly).not.toContain('p2'); + }); + + test('filters by --query', () => { + runLog('{"skill":"review","type":"pattern","key":"auth-bypass","insight":"check session tokens","confidence":7,"source":"observed"}'); + runLog('{"skill":"review","type":"pattern","key":"n-plus-one","insight":"use includes for associations","confidence":7,"source":"observed"}'); + + const authOnly = runSearch('--query auth'); + expect(authOnly).toContain('auth-bypass'); + expect(authOnly).not.toContain('n-plus-one'); + }); + + test('respects --limit', () => { + for (let i = 0; i < 5; i++) { + runLog(`{"skill":"review","type":"pattern","key":"limit-${i}","insight":"insight ${i}","confidence":7,"source":"observed"}`); + } + + const limited = runSearch('--limit 2'); + // Should show 2, not 5 + expect(limited).toContain('2 loaded'); + }); + + test('applies confidence decay for observed/inferred sources', () => { + // Entry from 90 days ago with source=observed, confidence=8 + // Should decay to 8 - floor(90/30) = 8 - 3 = 5 + const ts = new Date(Date.now() - 90 * 86400000).toISOString(); + runLog(`{"skill":"review","type":"pattern","key":"decay-test","insight":"old observation","confidence":8,"source":"observed","ts":"${ts}"}`); + + const output = runSearch(); + // Should show confidence 5 (decayed from 8) + expect(output).toContain('confidence: 5/10'); + }); + + test('does NOT decay user-stated learnings', () => { + const ts = new Date(Date.now() - 90 * 86400000).toISOString(); + runLog(`{"skill":"review","type":"preference","key":"no-decay-test","insight":"user preference","confidence":9,"source":"user-stated","ts":"${ts}"}`); + + const output = runSearch(); + // Should still show confidence 9 (no decay for user-stated) + expect(output).toContain('confidence: 9/10'); + }); + + test('skips malformed JSONL lines gracefully', () => { + // Write a valid entry, then manually append a bad line + runLog('{"skill":"review","type":"pattern","key":"valid-entry","insight":"valid","confidence":7,"source":"observed"}'); + const f = findLearningsFile(); + expect(f).not.toBeNull(); + fs.appendFileSync(f!, '\nthis is not json\n'); + fs.appendFileSync(f!, '{"skill":"review","type":"pattern","key":"also-valid","insight":"also valid","confidence":6,"source":"observed","ts":"2026-03-28T00:00:00Z"}\n'); + + const output = runSearch(); + expect(output).toContain('valid-entry'); + expect(output).toContain('also-valid'); + }); +});