Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/decay/__tests__/ui.test.ts

Raw
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> = {}): 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<typeof renderDecayFrame>[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();
		}
	});
});