jest.mock("fs"); jest.mock("../../src/workspace-detector", () => ({ getWorkspaceState: jest.fn(() => ({ isActive: true, root: "/workspace" })), })); import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import piFactory, { run, getBurnoutNudge, buildWeeklySummary, type WeeklyStats, type SessionRecord, } from "../../src/extensions/progress-tracker"; const TRACKER_DIR = path.join(os.homedir(), ".pisces"); const SESSIONS_FILE = path.join(TRACKER_DIR, "sessions.json"); const mFs = jest.mocked(fs); describe("progress-tracker", () => { beforeEach(() => { jest.clearAllMocks(); mFs.existsSync.mockReturnValue(false); (mFs.readFileSync as jest.Mock).mockReturnValue("[]"); }); // ─── run() ──────────────────────────────────────────────────────────────── describe("run()", () => { it("creates tracker dir when it does not exist", () => { run({ sessionDurationMinutes: 30, skillsUsed: [] }); expect(mFs.mkdirSync).toHaveBeenCalledWith(TRACKER_DIR, { recursive: true }); }); it("skips mkdir when tracker dir already exists", () => { mFs.existsSync.mockReturnValue(true); run({ sessionDurationMinutes: 30, skillsUsed: [] }); expect(mFs.mkdirSync).not.toHaveBeenCalled(); }); it("writes a session record with correct fields", () => { run({ sessionDurationMinutes: 45, skillsUsed: ["explain", "review"] }); expect(mFs.writeFileSync).toHaveBeenCalledWith(SESSIONS_FILE, expect.any(String)); const written: SessionRecord[] = JSON.parse( (mFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ); expect(written).toHaveLength(1); expect(written[0].durationMinutes).toBe(45); expect(written[0].skillsUsed).toEqual(["explain", "review"]); expect(written[0].date).toBeDefined(); }); it("appends to existing sessions on disk", () => { const existing: SessionRecord[] = [ { date: new Date().toISOString(), durationMinutes: 60, skillsUsed: ["homework"], topicsWorked: [] }, ]; mFs.existsSync.mockReturnValue(true); (mFs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(existing)); run({ sessionDurationMinutes: 30, skillsUsed: ["leetcode"] }); const written: SessionRecord[] = JSON.parse( (mFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ); expect(written).toHaveLength(2); expect(written[1].skillsUsed).toEqual(["leetcode"]); }); it("prunes sessions older than 90 days", () => { const oldDate = new Date(); oldDate.setDate(oldDate.getDate() - 91); const old: SessionRecord[] = [ { date: oldDate.toISOString(), durationMinutes: 60, skillsUsed: [], topicsWorked: [] }, ]; mFs.existsSync.mockReturnValue(true); (mFs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(old)); run({ sessionDurationMinutes: 30, skillsUsed: [] }); const written: SessionRecord[] = JSON.parse( (mFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ); expect(written).toHaveLength(1); expect(written[0].durationMinutes).toBe(30); }); it("keeps sessions within the 90-day window", () => { const recentDate = new Date(); recentDate.setDate(recentDate.getDate() - 89); const recent: SessionRecord[] = [ { date: recentDate.toISOString(), durationMinutes: 60, skillsUsed: [], topicsWorked: [] }, ]; mFs.existsSync.mockReturnValue(true); (mFs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(recent)); run({ sessionDurationMinutes: 30, skillsUsed: [] }); const written: SessionRecord[] = JSON.parse( (mFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ); expect(written).toHaveLength(2); }); it("falls back to empty sessions when sessions.json is malformed", () => { mFs.existsSync.mockReturnValue(true); (mFs.readFileSync as jest.Mock).mockReturnValue("{ not valid json }"); expect(() => run({ sessionDurationMinutes: 30, skillsUsed: [] })).not.toThrow(); const written: SessionRecord[] = JSON.parse( (mFs.writeFileSync as jest.Mock).mock.calls[0][1] as string ); expect(written).toHaveLength(1); }); it("does not throw when writeFileSync fails", () => { (mFs.writeFileSync as jest.Mock).mockImplementation(() => { throw new Error("disk full"); }); expect(() => run({ sessionDurationMinutes: 30, skillsUsed: [] })).not.toThrow(); }); it("returns a nudge for long sessions", () => { const { nudge } = run({ sessionDurationMinutes: 200, skillsUsed: [] }); expect(nudge).toBeTruthy(); expect(nudge).toContain("break"); }); it("returns null nudge for a short session with a light week", () => { const { nudge } = run({ sessionDurationMinutes: 30, skillsUsed: [] }); expect(nudge).toBeNull(); }); it("returns a weekly summary string", () => { const { weeklySummary } = run({ sessionDurationMinutes: 30, skillsUsed: ["homework"] }); expect(typeof weeklySummary).toBe("string"); expect(weeklySummary.length).toBeGreaterThan(0); }); }); // ─── getBurnoutNudge() ──────────────────────────────────────────────────── describe("getBurnoutNudge()", () => { const base: WeeklyStats = { totalMinutes: 0, sessionCount: 0, skillBreakdown: {}, longestSession: 0, streak: 0, }; it("returns nudge when session is >= 180 minutes", () => { expect(getBurnoutNudge(180, base)).toContain("break"); }); it("returns null for sessions under 180 minutes with a light week", () => { expect(getBurnoutNudge(60, base)).toBeNull(); }); it("returns nudge when weekly total exceeds 40 hours", () => { expect(getBurnoutNudge(30, { ...base, totalMinutes: 41 * 60 })).toContain("40+"); }); it("returns streak nudge for a 7-day streak", () => { expect(getBurnoutNudge(30, { ...base, streak: 7 })).toContain("7-day"); }); it("returns streak nudge for streaks longer than 7 days", () => { expect(getBurnoutNudge(30, { ...base, streak: 14 })).toContain("14-day"); }); it("session length check takes priority over weekly total", () => { const nudge = getBurnoutNudge(180, { ...base, totalMinutes: 41 * 60 }); expect(nudge).toContain("break"); }); }); // ─── buildWeeklySummary() ────────────────────────────────────────────────── describe("buildWeeklySummary()", () => { it("returns no-activity message when there are no sessions", () => { const stats: WeeklyStats = { totalMinutes: 0, sessionCount: 0, skillBreakdown: {}, longestSession: 0, streak: 0, }; expect(buildWeeklySummary(stats)).toContain("No activity"); }); it("formats time as hours and minutes when >= 60 minutes", () => { const stats: WeeklyStats = { totalMinutes: 90, sessionCount: 2, skillBreakdown: {}, longestSession: 60, streak: 1, }; expect(buildWeeklySummary(stats)).toContain("1h 30m"); }); it("formats time as minutes-only when under 60 minutes", () => { const stats: WeeklyStats = { totalMinutes: 45, sessionCount: 1, skillBreakdown: {}, longestSession: 45, streak: 0, }; expect(buildWeeklySummary(stats)).toContain("45m"); }); it("includes session count and streak", () => { const stats: WeeklyStats = { totalMinutes: 120, sessionCount: 3, skillBreakdown: {}, longestSession: 60, streak: 4, }; const summary = buildWeeklySummary(stats); expect(summary).toContain("3"); expect(summary).toContain("4 day"); }); it("lists top 3 skills by usage", () => { const stats: WeeklyStats = { totalMinutes: 200, sessionCount: 5, longestSession: 60, streak: 2, skillBreakdown: { homework: 4, explain: 3, leetcode: 2, review: 1 }, }; const summary = buildWeeklySummary(stats); expect(summary).toContain("/homework"); expect(summary).toContain("/explain"); expect(summary).toContain("/leetcode"); expect(summary).not.toContain("/review"); }); it("shows tip when study time is under 5 hours", () => { const stats: WeeklyStats = { totalMinutes: 4 * 60, sessionCount: 2, skillBreakdown: {}, longestSession: 120, streak: 1, }; expect(buildWeeklySummary(stats)).toContain("Tip"); }); it("shows encouragement when study time is 5 hours or more", () => { const stats: WeeklyStats = { totalMinutes: 5 * 60, sessionCount: 4, skillBreakdown: {}, longestSession: 90, streak: 3, }; expect(buildWeeklySummary(stats)).toContain("Solid"); }); }); }); // ─── Pi extension factory tests ─────────────────────────────────────────── describe("Pi extension factory (default export)", () => { type Handler = (...args: unknown[]) => Promise | unknown; function makeMockPi() { const handlers: Record = {}; const commands: Record = {}; return { on: jest.fn((event: string, handler: Handler) => { handlers[event] = handlers[event] ?? []; handlers[event].push(handler); }), sendUserMessage: jest.fn(), registerCommand: jest.fn((name: string, opts: { handler: Handler }) => { commands[name] = opts; }), _emit: async (event: string, ...args: unknown[]) => { for (const h of handlers[event] ?? []) await h(...args); }, _emitOne: async (event: string, ...args: unknown[]) => handlers[event]?.[0]?.(...args), _command: async (name: string, ...args: unknown[]) => commands[name]?.handler(...args), }; } function makeCtx() { return { ui: { notify: jest.fn(), setStatus: jest.fn() } }; } beforeEach(() => { jest.clearAllMocks(); mFs.existsSync.mockReturnValue(false); (mFs.readFileSync as jest.Mock).mockReturnValue("[]"); }); it("tracks skill name from /skill: prefixed input and returns continue", async () => { const pi = makeMockPi(); piFactory(pi as never); const result = await pi._emitOne("input", { text: "/skill:homework explain recursion" }); expect(result).toEqual({ action: "continue" }); }); it("returns continue for non-skill input without messaging", async () => { const pi = makeMockPi(); piFactory(pi as never); const result = await pi._emitOne("input", { text: "what is a linked list?" }); expect(result).toEqual({ action: "continue" }); expect(pi.sendUserMessage).not.toHaveBeenCalled(); }); it("session_shutdown records the session without showing UI", async () => { const pi = makeMockPi(); piFactory(pi as never); const ctx = makeCtx(); await pi._emit("session_shutdown", {}, ctx); expect(mFs.writeFileSync).toHaveBeenCalled(); expect(ctx.ui.notify).not.toHaveBeenCalled(); }); it("before_agent_start does not notify before 3-hour mark", async () => { const pi = makeMockPi(); piFactory(pi as never); const ctx = makeCtx(); await pi._emit("before_agent_start", {}, ctx); expect(ctx.ui.notify).not.toHaveBeenCalled(); }); it("/progress command shows weekly summary via notify fallback", async () => { const pi = makeMockPi(); piFactory(pi as never); const ctx = makeCtx(); await pi._command("progress", "", ctx); expect(ctx.ui.notify).toHaveBeenCalledWith( expect.stringContaining("week"), "info", ); }); it("/progress command warns when workspace is inactive", async () => { const { getWorkspaceState } = await import("../../src/workspace-detector"); (getWorkspaceState as jest.Mock).mockReturnValueOnce({ isActive: false }); const pi = makeMockPi(); piFactory(pi as never); const ctx = makeCtx(); await pi._command("progress", "", ctx); expect(ctx.ui.notify).toHaveBeenCalledWith(expect.any(String), "warning"); }); });