import { describe, expect, it, vi } from "vitest"; import type { MemoryAtom, RecordChunk } from "../core"; import { DecayComponent, type DecayDisplayState, plainFrame, renderDecayFrame, } from "../ui"; function state(overrides: Partial = {}): DecayDisplayState { return { model: "openai-codex/gpt-5.6-luna", inputTokens: 118_420, keptTokens: 24_000, totalChunks: 8, phase: "classify", records: [], startedAt: 1_000, ...overrides, }; } function record( chunk: number, atoms: RecordChunk["atoms"] = [ { key: `fact-${chunk}`, text: "fact", kind: "context", status: "active" }, ], of = 8, ): RecordChunk { return { chunk, of, atoms }; } function atom( key: string, kind: MemoryAtom["kind"] = "context", status: MemoryAtom["status"] = "active", ): MemoryAtom { return { key, text: key, kind, status, firstSeen: 1, lastSeen: 1, seenCount: 1, sourcePosition: 1, }; } const theme = { fg: vi.fn((_color: string, text: string) => text), bg: vi.fn((_color: string, text: string) => text), bold: (text: string) => text, inverse: vi.fn((text: string) => text), }; function component(displayState = state(), now: () => number = () => 1_000) { return new DecayComponent( { terminal: { rows: 30 }, requestRender: vi.fn(), } as never, theme as never, displayState, now, ); } function countStyle( row: ReturnType[number], style: string, ): number { return row.filter((cell) => cell.style === style).length; } describe("Decay terminal rendering", () => { it.each([1, 12, 20, 40, 80, 120, 160])( "keeps five borderless rows bounded at width %i", (width) => { for (const phase of ["classify", "merge"] as const) { const frame = plainFrame( renderDecayFrame(width, 15, state({ phase }), 5_000), ); expect(frame).toHaveLength(5); for (const line of frame) expect([...line]).toHaveLength(width); expect(frame.join("\n")).not.toMatch(/[╭╮╰╯┌┐└┘]/); } }, ); it("uses human-readable counts and aligns every map origin", () => { const frame = plainFrame(renderDecayFrame(120, 30, state(), 2_000)); expect(frame[0]).toContain("CLASSIFYING 0/8"); expect(frame[1]).toContain("CTX 118k │"); expect(frame[2]).toContain("FACTS 0 │"); expect(frame[3]).toContain("NEW ? │"); expect(frame.slice(1, 4).map((line) => line.indexOf("│"))).toEqual([ 12, 12, 12, ]); }); it("uses one continuous style under each CTX label and block", () => { const frame = renderDecayFrame(113, 30, state(), 2_000); const row = frame[1].slice(13); const sourceLabel = plainFrame(frame)[1].indexOf("~94k TO SUMMARY"); const keepLabel = plainFrame(frame)[1].indexOf("~24k KEEP"); expect(sourceLabel).toBeGreaterThanOrEqual(13); expect(keepLabel).toBeGreaterThan(sourceLabel); expect(frame[1][sourceLabel]?.style).toBe("sourceBlock"); expect(frame[1][keepLabel]?.style).toBe("keepBlock"); expect(row.every((cell) => cell.style !== "text")).toBe(true); }); it("replaces pending blocks from an out-of-order completed chunk", () => { const frame = renderDecayFrame( 45, 30, state({ records: [record(8)] }), 2_000, ); expect(plainFrame(frame)[0]).toContain("CLASSIFYING 1/8"); expect(countStyle(frame[2], "contextBlock")).toBeGreaterThan(0); expect(countStyle(frame[2], "pendingBlock")).toBeGreaterThan(0); expect( frame[2] .filter((cell) => cell.style === "pendingBlock") .every((cell) => cell.char === "░"), ).toBe(true); }); it("classifies only context as context and all other kinds as durable", () => { const kinds = [ "constraint", "decision", "goal", "state", "blocker", "exact", "context", ] as const; const frame = renderDecayFrame( 113, 30, state({ totalChunks: 1, records: [ record( 1, kinds.map((kind) => ({ key: kind, text: kind, kind, status: "active", })), 1, ), ], }), 2_000, ); expect(plainFrame(frame)[2]).toContain("6 DURABLE"); expect(plainFrame(frame)[2]).toContain("1 CONTEXT"); }); it("highlights selected canonical atoms by normalized key", () => { const candidates = [ atom(" Keep Me ", "decision"), atom("drop", "context"), ]; const frame = renderDecayFrame( 80, 30, state({ phase: "merge", candidates, selectedKeys: ["keep me"], }), 2_000, ); expect(countStyle(frame[2], "selectedBlock")).toBeGreaterThan(0); expect(countStyle(frame[2], "contextBlock")).toBeGreaterThan(0); expect(plainFrame(frame)[2]).toContain("DURABLE"); }); it("keeps retained context stationary while the new summary grows leftward", () => { const initial = renderDecayFrame( 113, 30, state({ inputTokens: 100_000, keptTokens: 20_000, phase: "merge", summaryTokens: 1_000, }), 2_000, ); const grown = renderDecayFrame( 113, 30, state({ inputTokens: 100_000, keptTokens: 20_000, phase: "merge", summaryTokens: 4_000, }), 2_000, ); const initialKeep = initial[3].findIndex( (cell) => cell.style === "keepBlock", ); const grownKeep = grown[3].findIndex((cell) => cell.style === "keepBlock"); expect(countStyle(initial[1], "keepBlock")).toBe(20); expect(countStyle(initial[3], "keepBlock")).toBe(20); expect(countStyle(initial[3], "selectedBlock")).toBe(1); expect(countStyle(grown[3], "selectedBlock")).toBe(4); expect(initialKeep).toBe(93); expect(grownKeep).toBe(initialKeep); expect(plainFrame(initial)[3]).toContain("NEW ~21k"); expect(plainFrame(grown)[3]).toContain("NEW ~24k"); }); it("shows retained context before the first summary token arrives", () => { const frame = renderDecayFrame( 80, 30, state({ phase: "merge", summaryTokens: undefined }), 2_000, ); const text = plainFrame(frame); const keepStart = frame[3].findIndex((cell) => cell.style === "keepBlock"); expect(keepStart).toBeGreaterThan(13); expect(text[0]).toContain("WRITING SUMMARY"); expect(text[3]).toContain("NEW ?"); expect(text[3].slice(keepStart)).toContain("KEEP"); }); it("keeps out-of-order chunk progress in source order", () => { const frame = renderDecayFrame( 45, 30, state({ records: [record(8)] }), 2_000, ); const row = frame[2]; const firstCompleted = row.findIndex( (cell) => cell.style === "contextBlock", ); expect(firstCompleted).toBeGreaterThan(13); expect( row .slice(13, firstCompleted) .every((cell) => cell.style === "pendingBlock"), ).toBe(true); expect( row.slice(firstCompleted).every((cell) => cell.style === "contextBlock"), ).toBe(true); }); it("shows selected memory count and recency markers", () => { const candidates = [ { ...atom("old", "context"), lastSeen: 1, seenCount: 1 }, { ...atom("repeat", "decision"), lastSeen: 2, seenCount: 2 }, { ...atom("new", "goal"), lastSeen: 3, seenCount: 1 }, ]; const frame = renderDecayFrame( 100, 30, state({ phase: "merge", candidates, selectedKeys: ["old", "repeat"], }), 2_000, ); expect(plainFrame(frame)[2]).toContain("MEM 2/3 │"); expect(plainFrame(frame)[2]).toContain("◆"); expect(plainFrame(frame)[4]).toContain("◆ recurring"); }); it("never substitutes the configured target for unknown retained tokens", () => { const frame = plainFrame( renderDecayFrame(80, 30, state({ keptTokens: undefined }), 2_000), ); expect(frame[1]).not.toContain("24k KEEP"); expect(frame[3]).toContain("NEW ?"); }); it("uses inverse semantic colors instead of selectedBg", () => { theme.fg.mockClear(); theme.bg.mockClear(); theme.inverse.mockClear(); const widget = component( state({ phase: "merge", candidates: [atom("one", "decision")], selectedKeys: ["one"], summaryTokens: 100, }), ); try { expect("handleInput" in widget).toBe(false); expect(widget.render(80)).toHaveLength(5); expect(theme.inverse).toHaveBeenCalled(); expect(theme.fg).toHaveBeenCalledWith("accent", expect.any(String)); expect(theme.fg).toHaveBeenCalledWith("mdLink", expect.any(String)); expect(theme.bg).not.toHaveBeenCalled(); } finally { widget.dispose(); } }); it("deduplicates records and ignores records outside the display run", () => { const widget = component(); try { widget.record(record(1)); widget.record(record(1, [])); widget.record(record(9)); widget.record(record(2, [], 9)); expect(widget.state.records).toEqual([{ chunk: 1, of: 8, atoms: [] }]); } finally { widget.dispose(); } }); it("requests immediate renders for merge and summary stream updates", () => { const requestRender = vi.fn(); const widget = new DecayComponent( { terminal: { rows: 30 }, requestRender } as never, theme as never, state(), () => 2_000, ); try { widget.merge([atom("one", "decision")], [atom("one", "decision")]); widget.setSummary(12); expect(requestRender).toHaveBeenCalledTimes(2); expect(widget.state.summaryTokens).toBe(12); expect(widget.state.selectedKeys).toEqual(["one"]); } finally { widget.dispose(); } }); it("stops its render timer when disposed", () => { vi.useFakeTimers(); try { const requestRender = vi.fn(); const widget = new DecayComponent( { terminal: { rows: 30 }, requestRender } as never, theme as never, state(), ); vi.advanceTimersByTime(110); expect(requestRender).toHaveBeenCalledTimes(2); widget.dispose(); widget.dispose(); vi.advanceTimersByTime(110); expect(requestRender).toHaveBeenCalledTimes(2); } finally { vi.useRealTimers(); } }); });