mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 03:12:32 +00:00
Master shipped v0.35.6.0 (floor-ratio search gate) and v0.35.7.0 (typed-claim trajectory + founder scorecard) ahead of this branch. Resolving the merge requires: 1. VERSION trio (VERSION, package.json, CHANGELOG.md top entry) bumped to 0.36.1.0 to claim the next slot after master's 0.35.7.0. 2. Migration v67 collision: master shipped facts_typed_claim_columns as v67. This branch's six calibration migrations renumber from v67-v72 to v68-v73. Master's v67 stays unchanged. 3. wave_version literal renamed 'v0.36.0.0' -> 'v0.36.1.0' across: migrate.ts DEFAULT clauses, pglite-schema.ts, schema-embedded.ts (regenerated), schema.sql, test fixtures, undo-wave logic, and every doc string referencing the wave. `gbrain calibration --undo-wave v0.36.1.0` is the new operator-facing reversal path. 4. test/regressions/v0.36.0.0-iron-rule.test.ts -> v0.36.1.0-iron-rule.test.ts so the regression-inventory filename tracks the actual release. 5. llms-full.txt + llms.txt regenerated against the updated docs. Verification: - bun run verify: green - bun run test: 7132 pass / 0 fail / 0 skip - Targeted migrate + bootstrap + cycle + undo-wave + nudge + cli tests: 207 pass / 0 fail Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
7.5 KiB
TypeScript
192 lines
7.5 KiB
TypeScript
/**
|
|
* v0.36.1.0 (T17 / D18 CDX-3) — undo-wave reversal tests.
|
|
*
|
|
* Hermetic. Mock engine wired to return canned row sets for each step.
|
|
*
|
|
* Tests cover:
|
|
* - dry-run: counts without writing
|
|
* - happy path: all 4 steps execute + return counts
|
|
* - resolved_by filter: only this wave's auto-grade is reverted; manual
|
|
* resolutions are NOT touched
|
|
* - empty wave: zero counts when no matching rows
|
|
* - take_grade_cache audit marked applied=false post-undo
|
|
* - gstack scrub: attempted only when --scrub-gstack passed
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import { undoWave } from '../src/core/calibration/undo-wave.ts';
|
|
import type { BrainEngine } from '../src/core/engine.ts';
|
|
|
|
interface MockEngineState {
|
|
// SELECT distinct take_id results
|
|
targetTakeIds: number[];
|
|
// takes UPDATE...RETURNING ids
|
|
revertedTakes: number[];
|
|
// dry-run counts
|
|
resolutionCount: number;
|
|
gradeCacheCount: number;
|
|
profilesCount: number;
|
|
nudgesCount: number;
|
|
// non-dry-run RETURNING shapes
|
|
gradeCacheRows: number[];
|
|
profilesRows: number[];
|
|
nudgesRows: number[];
|
|
}
|
|
|
|
interface SqlCall { sql: string; params: unknown[] }
|
|
|
|
function buildMockEngine(state: Partial<MockEngineState>): { engine: BrainEngine; sqls: SqlCall[] } {
|
|
const sqls: SqlCall[] = [];
|
|
const engine = {
|
|
kind: 'pglite',
|
|
async executeRaw<T>(sql: string, params?: unknown[]): Promise<T[]> {
|
|
sqls.push({ sql, params: params ?? [] });
|
|
// SELECT distinct take_id
|
|
if (sql.includes('SELECT DISTINCT take_id FROM take_grade_cache')) {
|
|
return (state.targetTakeIds ?? []).map(id => ({ take_id: id })) as unknown as T[];
|
|
}
|
|
// dry-run count: takes
|
|
if (sql.includes('FROM takes') && sql.includes('COUNT(*)')) {
|
|
return [{ count: state.resolutionCount ?? 0 } as unknown as T];
|
|
}
|
|
// UPDATE takes... RETURNING
|
|
if (sql.includes('UPDATE takes')) {
|
|
return (state.revertedTakes ?? []).map(id => ({ id })) as unknown as T[];
|
|
}
|
|
// dry-run count: take_grade_cache
|
|
if (sql.includes('FROM take_grade_cache') && sql.includes('COUNT(*)')) {
|
|
return [{ count: state.gradeCacheCount ?? 0 } as unknown as T];
|
|
}
|
|
// UPDATE take_grade_cache
|
|
if (sql.includes('UPDATE take_grade_cache')) {
|
|
return (state.gradeCacheRows ?? []).map(take_id => ({ take_id })) as unknown as T[];
|
|
}
|
|
// dry-run count: calibration_profiles
|
|
if (sql.includes('FROM calibration_profiles') && sql.includes('COUNT(*)')) {
|
|
return [{ count: state.profilesCount ?? 0 } as unknown as T];
|
|
}
|
|
// DELETE calibration_profiles RETURNING
|
|
if (sql.includes('DELETE FROM calibration_profiles')) {
|
|
return (state.profilesRows ?? []).map(id => ({ id })) as unknown as T[];
|
|
}
|
|
// dry-run count: take_nudge_log
|
|
if (sql.includes('FROM take_nudge_log') && sql.includes('COUNT(*)')) {
|
|
return [{ count: state.nudgesCount ?? 0 } as unknown as T];
|
|
}
|
|
// DELETE take_nudge_log RETURNING
|
|
if (sql.includes('DELETE FROM take_nudge_log')) {
|
|
return (state.nudgesRows ?? []).map(id => ({ id })) as unknown as T[];
|
|
}
|
|
return [];
|
|
},
|
|
} as unknown as BrainEngine;
|
|
return { engine, sqls };
|
|
}
|
|
|
|
describe('undoWave — dry-run posture', () => {
|
|
test('dryRun=true returns counts without UPDATE/DELETE', async () => {
|
|
const { engine, sqls } = buildMockEngine({
|
|
targetTakeIds: [1, 2, 3],
|
|
resolutionCount: 2,
|
|
gradeCacheCount: 3,
|
|
profilesCount: 1,
|
|
nudgesCount: 8,
|
|
});
|
|
const out = await undoWave(engine, { waveVersion: 'v0.36.1.0', dryRun: true });
|
|
expect(out.dry_run).toBe(true);
|
|
expect(out.resolutions_reverted).toBe(2);
|
|
expect(out.grade_cache_unapplied).toBe(3);
|
|
expect(out.profiles_deleted).toBe(1);
|
|
expect(out.nudges_purged).toBe(8);
|
|
// NO UPDATE/DELETE SQL emitted on dry-run.
|
|
expect(sqls.find(s => s.sql.includes('UPDATE takes'))).toBeUndefined();
|
|
expect(sqls.find(s => s.sql.includes('DELETE FROM'))).toBeUndefined();
|
|
expect(sqls.find(s => s.sql.includes('UPDATE take_grade_cache'))).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('undoWave — happy path', () => {
|
|
test('all 4 steps execute + return counts', async () => {
|
|
const { engine, sqls } = buildMockEngine({
|
|
targetTakeIds: [10, 11, 12],
|
|
revertedTakes: [10, 11], // 12 was overridden by a manual resolve, skipped
|
|
gradeCacheRows: [10, 11, 12],
|
|
profilesRows: [101],
|
|
nudgesRows: [201, 202, 203, 204],
|
|
});
|
|
const out = await undoWave(engine, { waveVersion: 'v0.36.1.0' });
|
|
expect(out.dry_run).toBe(false);
|
|
expect(out.resolutions_reverted).toBe(2);
|
|
expect(out.grade_cache_unapplied).toBe(3);
|
|
expect(out.profiles_deleted).toBe(1);
|
|
expect(out.nudges_purged).toBe(4);
|
|
expect(out.gstack_scrub_attempted).toBe(false); // not opted in
|
|
// Verify wave_version parameter threaded everywhere.
|
|
const insertWaveParams = sqls.filter(s => Array.isArray(s.params) && (s.params as unknown[])[0] === 'v0.36.1.0');
|
|
expect(insertWaveParams.length).toBeGreaterThan(2);
|
|
});
|
|
|
|
test('resolved_by filter: UPDATE takes scoped to wave-applied resolutions only', async () => {
|
|
const { engine, sqls } = buildMockEngine({
|
|
targetTakeIds: [1, 2],
|
|
revertedTakes: [1, 2],
|
|
});
|
|
await undoWave(engine, { waveVersion: 'v0.36.1.0' });
|
|
const updateCall = sqls.find(s => s.sql.includes('UPDATE takes'));
|
|
expect(updateCall).toBeDefined();
|
|
// resolved_by parameter is $2 = 'gbrain:grade_takes' (default label)
|
|
expect(updateCall!.params[1]).toBe('gbrain:grade_takes');
|
|
});
|
|
|
|
test('custom resolvedByLabel is honored', async () => {
|
|
const { engine, sqls } = buildMockEngine({
|
|
targetTakeIds: [1],
|
|
revertedTakes: [1],
|
|
});
|
|
await undoWave(engine, { waveVersion: 'v0.36.1.0', resolvedByLabel: 'gbrain:grade_takes-custom' });
|
|
const updateCall = sqls.find(s => s.sql.includes('UPDATE takes'));
|
|
expect(updateCall!.params[1]).toBe('gbrain:grade_takes-custom');
|
|
});
|
|
});
|
|
|
|
describe('undoWave — empty wave', () => {
|
|
test('zero counts when no matching rows', async () => {
|
|
const { engine } = buildMockEngine({
|
|
targetTakeIds: [],
|
|
revertedTakes: [],
|
|
gradeCacheRows: [],
|
|
profilesRows: [],
|
|
nudgesRows: [],
|
|
});
|
|
const out = await undoWave(engine, { waveVersion: 'v0.36.1.0' });
|
|
expect(out.resolutions_reverted).toBe(0);
|
|
expect(out.grade_cache_unapplied).toBe(0);
|
|
expect(out.profiles_deleted).toBe(0);
|
|
expect(out.nudges_purged).toBe(0);
|
|
});
|
|
|
|
test('idempotent: re-running undo finds nothing', async () => {
|
|
const { engine } = buildMockEngine({});
|
|
const out1 = await undoWave(engine, { waveVersion: 'v0.36.1.0' });
|
|
const out2 = await undoWave(engine, { waveVersion: 'v0.36.1.0' });
|
|
expect(out1.resolutions_reverted).toBe(0);
|
|
expect(out2.resolutions_reverted).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('undoWave — wave_version parameter is threaded through all queries', () => {
|
|
test('queries use the supplied wave version', async () => {
|
|
const { engine, sqls } = buildMockEngine({});
|
|
await undoWave(engine, { waveVersion: 'v0.36.1.0' });
|
|
const waveVersionUsedAsParam0 = sqls.filter(s => s.params[0] === 'v0.36.1.0').length;
|
|
expect(waveVersionUsedAsParam0).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
test('different wave versions DO NOT collide', async () => {
|
|
const { engine, sqls } = buildMockEngine({});
|
|
await undoWave(engine, { waveVersion: 'v0.37.0.0' });
|
|
expect(sqls.find(s => s.params[0] === 'v0.37.0.0')).toBeDefined();
|
|
expect(sqls.find(s => s.params[0] === 'v0.36.1.0')).toBeUndefined();
|
|
});
|
|
});
|