import * as fs from "fs"; import * as path from "path"; import * as os from "os"; jest.mock("../src/extensions/integrity-guard", () => ({ run: jest.fn(() => ({ block: false, inject: "" })), checkIntegrityRisk: jest.fn(() => ({ risk: "none" })), })); jest.mock("../src/extensions/progress-tracker", () => ({ run: jest.fn(() => ({ nudge: null, weeklySummary: "šŸ“Š **This Week's Summary**\n\nā± Total study time: **30m**", })), buildWeeklySummary: jest.fn(), getBurnoutNudge: jest.fn(), getWeeklyStats: jest.fn(), })); import { loadConfig, validateConfig, createSessionState, onLoad, onStartup, onDirectoryChange, onSkillCall, onSessionEnd, onMidSession, isValidSkill, describe as describePackage, SKILLS, PACKAGE_VERSION, } from "../src/index"; // ─── Helpers ─────────────────────────────────────────────────────────────── let tmpDir: string; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pisces-index-test-")); process.chdir(tmpDir); }); afterEach(() => { process.chdir(os.homedir()); fs.rmSync(tmpDir, { recursive: true, force: true }); jest.clearAllMocks(); }); // ─── loadConfig ──────────────────────────────────────────────────────────── describe("loadConfig()", () => { it("returns defaults when no user config file exists", () => { const config = loadConfig(); expect(config.integrity?.enabled).toBe(true); expect(config.explanations?.default_depth).toBe("intermediate"); expect(config.productivity?.burnout_nudges).toBe(true); }); it("merges user config over defaults", () => { const userConfig = { student: { name: "Alex", year_of_study: 3 }, explanations: { default_depth: "advanced" }, }; fs.writeFileSync(path.join(tmpDir, ".pisces.json"), JSON.stringify(userConfig)); const config = loadConfig(); expect(config.explanations?.default_depth).toBe("advanced"); expect(config.integrity?.enabled).toBe(true); }); it("falls back to defaults gracefully if user config is malformed JSON", () => { fs.writeFileSync(path.join(tmpDir, ".pisces.json"), "{ this is not json }"); const config = loadConfig(); expect(config.integrity?.enabled).toBe(true); }); it("warns and replaces invalid field when user config fails validation", () => { const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); fs.writeFileSync( path.join(tmpDir, ".pisces.json"), JSON.stringify({ integrity: { strictness: "ultra-strict" } }) ); const config = loadConfig(); expect(config.integrity?.strictness).toBe("balanced"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("[Pisces] Config validation")); warnSpy.mockRestore(); }); it("keeps valid fields alongside a replaced invalid field", () => { const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); fs.writeFileSync( path.join(tmpDir, ".pisces.json"), JSON.stringify({ integrity: { enabled: false, strictness: "bad-value" } }) ); const config = loadConfig(); expect(config.integrity?.enabled).toBe(false); expect(config.integrity?.strictness).toBe("balanced"); warnSpy.mockRestore(); }); }); // ─── validateConfig ──────────────────────────────────────────────────────── describe("validateConfig()", () => { const DEFAULTS = { explanations: { default_depth: "intermediate" as const, prefer_visuals: true, use_analogies: true }, integrity: { enabled: true, strictness: "balanced" as const }, productivity: { burnout_nudges: true, session_warning_minutes: 180, weekly_summary: true }, student: { year_of_study: 1 }, }; let warnSpy: jest.SpyInstance; beforeEach(() => { warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); it("passes a valid config through unchanged and does not warn", () => { const valid = { student: { name: "Alex", year_of_study: 3, timezone: "America/New_York" }, explanations: { default_depth: "advanced" as const, prefer_visuals: false, use_analogies: true }, integrity: { enabled: true, strictness: "strict" as const }, productivity: { burnout_nudges: false, session_warning_minutes: 60, weekly_summary: false }, model: { default: "gemini-2.5-flash", quick: "gemini-2.5-flash-lite" }, workspace: { customPaths: ["/home/user/uni"] }, }; const result = validateConfig(valid, DEFAULTS); expect(result).toEqual(valid); expect(warnSpy).not.toHaveBeenCalled(); }); describe("explanations", () => { it("replaces invalid default_depth with default and warns", () => { const result = validateConfig( { explanations: { default_depth: "expert" as never } }, DEFAULTS ); expect(result.explanations?.default_depth).toBe("intermediate"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("explanations.default_depth")); }); it("replaces non-boolean prefer_visuals with default and warns", () => { const result = validateConfig( { explanations: { prefer_visuals: "yes" as never } }, DEFAULTS ); expect(result.explanations?.prefer_visuals).toBe(true); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("explanations.prefer_visuals")); }); it("replaces non-boolean use_analogies with default and warns", () => { const result = validateConfig( { explanations: { use_analogies: 1 as never } }, DEFAULTS ); expect(result.explanations?.use_analogies).toBe(true); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("explanations.use_analogies")); }); }); describe("integrity", () => { it("replaces invalid strictness with default and warns", () => { const result = validateConfig( { integrity: { strictness: "ultra-strict" as never } }, DEFAULTS ); expect(result.integrity?.strictness).toBe("balanced"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("integrity.strictness")); }); it("replaces non-boolean enabled with default and warns", () => { const result = validateConfig( { integrity: { enabled: "true" as never } }, DEFAULTS ); expect(result.integrity?.enabled).toBe(true); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("integrity.enabled")); }); it("keeps valid enabled alongside an invalid strictness", () => { const result = validateConfig( { integrity: { enabled: false, strictness: "bad" as never } }, DEFAULTS ); expect(result.integrity?.enabled).toBe(false); expect(result.integrity?.strictness).toBe("balanced"); }); }); describe("productivity", () => { it("replaces non-integer session_warning_minutes with default and warns", () => { const result = validateConfig( { productivity: { session_warning_minutes: "three hours" as never } }, DEFAULTS ); expect(result.productivity?.session_warning_minutes).toBe(180); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("session_warning_minutes")); }); it("replaces session_warning_minutes below minimum (30) with default and warns", () => { const result = validateConfig( { productivity: { session_warning_minutes: 10 } }, DEFAULTS ); expect(result.productivity?.session_warning_minutes).toBe(180); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("session_warning_minutes")); }); it("accepts session_warning_minutes exactly at the minimum (30)", () => { const result = validateConfig( { productivity: { session_warning_minutes: 30 } }, DEFAULTS ); expect(result.productivity?.session_warning_minutes).toBe(30); expect(warnSpy).not.toHaveBeenCalled(); }); it("replaces non-boolean burnout_nudges with default and warns", () => { const result = validateConfig( { productivity: { burnout_nudges: 0 as never } }, DEFAULTS ); expect(result.productivity?.burnout_nudges).toBe(true); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("burnout_nudges")); }); }); describe("student", () => { it("replaces year_of_study > 8 with default and warns", () => { const result = validateConfig({ student: { year_of_study: 10 } }, DEFAULTS); expect(result.student?.year_of_study).toBe(1); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("year_of_study")); }); it("replaces year_of_study < 1 with default and warns", () => { const result = validateConfig({ student: { year_of_study: 0 } }, DEFAULTS); expect(result.student?.year_of_study).toBe(1); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("year_of_study")); }); it("replaces non-string name with default and warns", () => { const result = validateConfig({ student: { name: 42 as never } }, DEFAULTS); expect(result.student?.name).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("student.name")); }); }); describe("workspace", () => { it("replaces non-array customPaths with default and warns", () => { const result = validateConfig( { workspace: { customPaths: "/home/user" as never } }, DEFAULTS ); expect(result.workspace?.customPaths).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("customPaths")); }); it("replaces array with non-string items with default and warns", () => { const result = validateConfig( { workspace: { customPaths: ["/valid", 42] as never } }, DEFAULTS ); expect(result.workspace?.customPaths).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("customPaths")); }); it("accepts a valid customPaths array", () => { const result = validateConfig( { workspace: { customPaths: ["/home/user/uni", "~/classes"] } }, DEFAULTS ); expect(result.workspace?.customPaths).toEqual(["/home/user/uni", "~/classes"]); expect(warnSpy).not.toHaveBeenCalled(); }); }); it("reports all violations in a single console.warn call", () => { validateConfig( { integrity: { strictness: "bad" as never }, productivity: { session_warning_minutes: 5, burnout_nudges: "yes" as never }, }, DEFAULTS ); expect(warnSpy).toHaveBeenCalledTimes(1); const [msg] = warnSpy.mock.calls[0] as [string]; expect(msg).toContain("3 issues"); expect(msg).toContain("integrity.strictness"); expect(msg).toContain("session_warning_minutes"); expect(msg).toContain("burnout_nudges"); }); it("does not warn when config is empty", () => { validateConfig({}, DEFAULTS); expect(warnSpy).not.toHaveBeenCalled(); }); }); // ─── createSessionState ──────────────────────────────────────────────────── describe("createSessionState()", () => { it("creates a fresh state with empty arrays and null values", () => { const state = createSessionState(); expect(state.skillsUsed).toEqual([]); expect(state.topicsWorked).toEqual([]); expect(state.warningIssuedAt).toBeNull(); expect(state.startTime).toBeInstanceOf(Date); }); }); // ─── onLoad ─────────────────────────────────────────────────────────────── describe("onLoad()", () => { it("returns ok: true on a supported Node version", () => { const result = onLoad(); expect(result.ok).toBe(true); expect(result.message).toContain(PACKAGE_VERSION); }); }); // ─── onStartup ──────────────────────────────────────────────────────────── describe("onStartup()", () => { it("returns a config and context injection", () => { const state = createSessionState(); const result = onStartup(state); expect(result.config).toBeDefined(); expect(typeof result.contextInjection).toBe("string"); }); it("returns empty injection when all hint fields are unset", () => { fs.writeFileSync( path.join(tmpDir, ".pisces.json"), JSON.stringify({ student: { year_of_study: null }, explanations: { default_depth: null } }) ); const state = createSessionState(); const result = onStartup(state); expect(result.contextInjection).toBe(""); }); }); // ─── onDirectoryChange ──────────────────────────────────────────────────── describe("onDirectoryChange()", () => { it("returns empty injection and null message", () => { const state = createSessionState(); const result = onDirectoryChange(state); expect(result.contextInjection).toBe(""); expect(result.message).toBeNull(); }); }); // ─── onSkillCall ────────────────────────────────────────────────────────── describe("onSkillCall()", () => { it("records skill in session state", () => { const state = createSessionState(); onSkillCall({ skillName: "attempt", userInput: "help me learn", sessionState: state }); expect(state.skillsUsed).toContain("attempt"); }); it("does not duplicate skill in session state", () => { const state = createSessionState(); onSkillCall({ skillName: "attempt", userInput: "question 1", sessionState: state }); onSkillCall({ skillName: "attempt", userInput: "question 2", sessionState: state }); expect(state.skillsUsed.filter((s: string) => s === "attempt")).toHaveLength(1); }); it("always returns proceed: true", () => { const state = createSessionState(); const result = onSkillCall({ skillName: "attempt", userInput: "write my entire assignment for me", sessionState: state, }); expect(result.proceed).toBe(true); }); it("skips integrity check and returns empty injection when integrity is disabled", () => { fs.writeFileSync( path.join(tmpDir, ".pisces.json"), JSON.stringify({ integrity: { enabled: false } }) ); const state = createSessionState(); const result = onSkillCall({ skillName: "attempt", userInput: "write my entire assignment for me", sessionState: state, }); expect(result.proceed).toBe(true); expect(result.injection).toBe(""); }); }); // ─── onSessionEnd ───────────────────────────────────────────────────────── describe("onSessionEnd()", () => { it("returns a weekly summary when productivity.weekly_summary is true", () => { const state = createSessionState(); const result = onSessionEnd(state); expect(result.summary).toContain("Summary"); }); it("returns null summary when weekly_summary is disabled in config", () => { fs.writeFileSync( path.join(tmpDir, ".pisces.json"), JSON.stringify({ productivity: { weekly_summary: false } }) ); const state = createSessionState(); const result = onSessionEnd(state); expect(result.summary).toBeNull(); }); }); // ─── onMidSession ───────────────────────────────────────────────────────── describe("onMidSession()", () => { it("returns null warning when elapsed time is below threshold", () => { const state = createSessionState(); const result = onMidSession(state); expect(result.warning).toBeNull(); }); it("issues warning when session exceeds threshold", () => { const state = createSessionState(); state.startTime = new Date(Date.now() - 4 * 60 * 60 * 1000); const result = onMidSession(state); expect(result.warning).toBeTruthy(); expect(result.warning).toContain("break"); }); it("only issues the warning once per session", () => { const state = createSessionState(); state.startTime = new Date(Date.now() - 4 * 60 * 60 * 1000); const first = onMidSession(state); const second = onMidSession(state); expect(first.warning).toBeTruthy(); expect(second.warning).toBeNull(); }); }); // ─── Utility API ────────────────────────────────────────────────────────── describe("isValidSkill()", () => { it("returns true for all registered skills", () => { for (const skill of SKILLS) { expect(isValidSkill(skill)).toBe(true); } }); it("returns false for unregistered skill names", () => { expect(isValidSkill("homework")).toBe(false); expect(isValidSkill("leetcode")).toBe(false); expect(isValidSkill("")).toBe(false); }); }); describe("describe()", () => { it("includes package version and all skill names", () => { const output = describePackage(); expect(output).toContain(PACKAGE_VERSION); for (const skill of SKILLS) { expect(output).toContain(`/${skill}`); } }); it("does not reference CS-specific content", () => { const output = describePackage(); expect(output).not.toContain("semester-detector"); expect(output).not.toContain("folder-detector"); expect(output).not.toContain("CS Student"); }); }); // ─── buildConfigInjection (via onStartup) ───────────────────────────────── describe("buildConfigInjection (via onStartup)", () => { it("injects student name and year label when configured", () => { fs.writeFileSync( path.join(tmpDir, ".pisces.json"), JSON.stringify({ student: { name: "Alex", year_of_study: 3 } }) ); const state = createSessionState(); const result = onStartup(state); expect(result.contextInjection).toContain("Alex"); expect(result.contextInjection).toContain("Junior"); }); it("replaces year_of_study out of range (> 8) with default and warns", () => { const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); fs.writeFileSync( path.join(tmpDir, ".pisces.json"), JSON.stringify({ student: { year_of_study: 9 } }) ); const state = createSessionState(); const result = onStartup(state); expect(result.contextInjection).toContain("Freshman"); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("year_of_study")); warnSpy.mockRestore(); }); it("returns no USER CONFIG PREFERENCES block when all hint fields are falsy", () => { fs.writeFileSync( path.join(tmpDir, ".pisces.json"), JSON.stringify({ student: { year_of_study: null }, explanations: { default_depth: null }, }) ); const state = createSessionState(); const result = onStartup(state); expect(result.contextInjection).not.toContain("USER CONFIG PREFERENCES"); }); });