jest.mock("fs"); jest.mock("../src/memory-policy", () => ({ validateWrite: jest.fn(() => ({ allowed: true })), validateInject: jest.fn(() => ({ allowed: true })), })); import * as fs from "fs"; import { appendAttempt, getRecentGaps, updateAttemptRecord, type AttemptRecord } from "../src/correction-memory"; import { validateWrite, validateInject } from "../src/memory-policy"; const mockFs = fs as jest.Mocked; function makeRecord(overrides: Partial = {}): AttemptRecord { return { id: "test-id", timestamp: "2026-07-03T00:00:00.000Z", skillName: "attempt", attemptType: "code", gaps: [], strengths: [], ...overrides, }; } beforeEach(() => { jest.clearAllMocks(); mockFs.existsSync.mockReturnValue(false); (validateWrite as jest.Mock).mockReturnValue({ allowed: true }); (validateInject as jest.Mock).mockReturnValue({ allowed: true }); }); describe("appendAttempt()", () => { it("writes a new record to history file", () => { mockFs.mkdirSync.mockImplementation(() => undefined); mockFs.writeFileSync.mockImplementation(() => undefined); appendAttempt(makeRecord({ id: "r1", gaps: ["missed edge case"] })); expect(mockFs.writeFileSync).toHaveBeenCalled(); const written = JSON.parse( (mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ) as AttemptRecord[]; expect(written).toHaveLength(1); expect(written[0].id).toBe("r1"); }); it("appends to existing records", () => { const existing: AttemptRecord[] = [makeRecord({ id: "old" })]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(existing)); mockFs.mkdirSync.mockImplementation(() => undefined); mockFs.writeFileSync.mockImplementation(() => undefined); appendAttempt(makeRecord({ id: "new" })); const written = JSON.parse( (mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ) as AttemptRecord[]; expect(written).toHaveLength(2); expect(written[1].id).toBe("new"); }); it("silently drops the record when validateWrite rejects", () => { (validateWrite as jest.Mock).mockReturnValue({ allowed: false, reason: "softening" }); mockFs.mkdirSync.mockImplementation(() => undefined); mockFs.writeFileSync.mockImplementation(() => undefined); appendAttempt(makeRecord()); expect(mockFs.writeFileSync).not.toHaveBeenCalled(); }); it("does not throw when fs.writeFileSync fails", () => { mockFs.mkdirSync.mockImplementation(() => undefined); mockFs.writeFileSync.mockImplementation(() => { throw new Error("disk full"); }); expect(() => appendAttempt(makeRecord())).not.toThrow(); }); }); describe("getRecentGaps()", () => { it("returns empty string when no history exists", () => { mockFs.existsSync.mockReturnValue(false); expect(getRecentGaps()).toBe(""); }); it("returns empty string when all records have no gaps", () => { const records: AttemptRecord[] = [makeRecord({ gaps: [] }), makeRecord({ gaps: [] })]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(records)); expect(getRecentGaps()).toBe(""); }); it("returns a summary containing each unique gap", () => { const records: AttemptRecord[] = [ makeRecord({ gaps: ["no null check", "missing early return"] }), ]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(records)); const result = getRecentGaps(); expect(result).toContain("no null check"); expect(result).toContain("missing early return"); }); it("deduplicates gaps that appear in multiple records", () => { const records: AttemptRecord[] = [ makeRecord({ gaps: ["no null check"] }), makeRecord({ gaps: ["no null check", "missing tests"] }), ]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(records)); const result = getRecentGaps(); // "no null check" should appear only once expect((result.match(/no null check/g) ?? []).length).toBe(1); expect(result).toContain("missing tests"); }); it("truncates to maxChars budget", () => { const longGaps = Array.from({ length: 100 }, (_, i) => `gap number ${i} is very descriptive and long`); const records: AttemptRecord[] = [makeRecord({ gaps: longGaps })]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(records)); const result = getRecentGaps(200); expect(result.length).toBeLessThanOrEqual(210); // some tolerance for header }); it("returns empty string when validateInject rejects the summary", () => { const records: AttemptRecord[] = [makeRecord({ gaps: ["some gap"] })]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(records)); (validateInject as jest.Mock).mockReturnValue({ allowed: false, reason: "bad framing" }); expect(getRecentGaps()).toBe(""); }); }); describe("updateAttemptRecord()", () => { it("updates gaps and strengths on a matching record", () => { const existing: AttemptRecord[] = [makeRecord({ id: "abc", gaps: [], strengths: [] })]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(existing)); mockFs.mkdirSync.mockImplementation(() => undefined); mockFs.writeFileSync.mockImplementation(() => undefined); updateAttemptRecord("abc", { gaps: ["missed edge case"], strengths: ["good naming"] }); const written = JSON.parse( (mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ) as AttemptRecord[]; expect(written[0].gaps).toEqual(["missed edge case"]); expect(written[0].strengths).toEqual(["good naming"]); }); it("updates score when provided", () => { const existing: AttemptRecord[] = [makeRecord({ id: "abc", score: undefined })]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(existing)); mockFs.mkdirSync.mockImplementation(() => undefined); mockFs.writeFileSync.mockImplementation(() => undefined); updateAttemptRecord("abc", { score: 73 }); const written = JSON.parse( (mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ) as AttemptRecord[]; expect(written[0].score).toBe(73); }); it("preserves other fields when updating", () => { const existing: AttemptRecord[] = [makeRecord({ id: "abc", goal: "sort a list", attemptType: "code" })]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(existing)); mockFs.mkdirSync.mockImplementation(() => undefined); mockFs.writeFileSync.mockImplementation(() => undefined); updateAttemptRecord("abc", { gaps: ["missing test"] }); const written = JSON.parse( (mockFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ) as AttemptRecord[]; expect(written[0].goal).toBe("sort a list"); expect(written[0].attemptType).toBe("code"); }); it("is a no-op when the ID is not found", () => { const existing: AttemptRecord[] = [makeRecord({ id: "abc" })]; mockFs.existsSync.mockReturnValue(true); mockFs.readFileSync.mockReturnValue(JSON.stringify(existing)); mockFs.mkdirSync.mockImplementation(() => undefined); mockFs.writeFileSync.mockImplementation(() => undefined); updateAttemptRecord("does-not-exist", { gaps: ["some gap"] }); expect(mockFs.writeFileSync).not.toHaveBeenCalled(); }); it("does not throw when the history file does not exist", () => { mockFs.existsSync.mockReturnValue(false); expect(() => updateAttemptRecord("abc", { gaps: ["gap"] })).not.toThrow(); }); });