repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/decay/__tests__/ui.test.ts
Rawimport { 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();
}
});
});