test: add frame-writer parity tests from Ink log-update (+41)
Port all 35 test declarations from Ink's log-update test suite, producing 41 new runtime tests (6 cursor tests run in both standard and incremental modes via describe.each). Covers standard rendering, incremental rendering (surgical updates, shrink, grow), clear/done reset, sync+update, cursor positioning, no-trailing-newline fullscreen mode, and render-to-empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,61 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { expect, test } from "vite-plus/test";
|
||||
import { EventEmitter } from "node:events";
|
||||
import ansiEscapes from "ansi-escapes";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { createFrameWriter } from "./frame-writer.ts";
|
||||
import logUpdate from "./log-update.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WriteSpy {
|
||||
(...args: unknown[]): boolean;
|
||||
callCount: number;
|
||||
firstCall: { args: unknown[] };
|
||||
secondCall: { args: unknown[] };
|
||||
calls: unknown[][];
|
||||
}
|
||||
|
||||
interface FakeStdout extends NodeJS.WriteStream {
|
||||
/** Last write arg */
|
||||
get: () => string;
|
||||
write: WriteSpy;
|
||||
}
|
||||
|
||||
function createStdout(): FakeStdout {
|
||||
const stdout = new EventEmitter() as unknown as FakeStdout;
|
||||
stdout.columns = 100;
|
||||
stdout.isTTY = true;
|
||||
|
||||
const calls: unknown[][] = [];
|
||||
const writeFn = ((...args: unknown[]) => {
|
||||
calls.push(args);
|
||||
writeFn.callCount = calls.length;
|
||||
return true;
|
||||
}) as WriteSpy;
|
||||
|
||||
writeFn.callCount = 0;
|
||||
writeFn.calls = calls;
|
||||
|
||||
Object.defineProperty(writeFn, "firstCall", {
|
||||
get: () => ({ args: calls[0] ?? [] }),
|
||||
});
|
||||
Object.defineProperty(writeFn, "secondCall", {
|
||||
get: () => ({ args: calls[1] ?? [] }),
|
||||
});
|
||||
|
||||
stdout.write = writeFn;
|
||||
stdout.get = () => calls[calls.length - 1]![0] as string;
|
||||
return stdout;
|
||||
}
|
||||
|
||||
const showCursorEscape = "[?25h";
|
||||
const hideCursorEscape = "[?25l";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Debug mode (existing test)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("debug mode writes complete frames terminated by newline", () => {
|
||||
const writes: string[] = [];
|
||||
@@ -15,3 +70,571 @@ test("debug mode writes complete frames terminated by newline", () => {
|
||||
|
||||
expect(writes).toEqual(["hello\n", "world\n"]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Standard rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("standard rendering", () => {
|
||||
test("renders and updates output", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render("Hello\n");
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
expect(stdout.write.firstCall.args[0]).toBe("Hello\n");
|
||||
|
||||
render("World\n");
|
||||
expect(stdout.write.callCount).toBe(2);
|
||||
expect((stdout.write.secondCall.args[0] as string).includes("World")).toBe(true);
|
||||
});
|
||||
|
||||
test("skips identical output", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render("Hello\n");
|
||||
render("Hello\n");
|
||||
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
});
|
||||
|
||||
test("positions cursor after output when cursorPosition is set", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 1 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
const written = stdout.write.firstCall.args[0] as string;
|
||||
expect(written.includes("Line 3")).toBe(true);
|
||||
expect(
|
||||
written.endsWith(ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("hides cursor before erase when cursor was previously shown", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.setCursorPosition({ x: 0, y: 0 });
|
||||
render("Hello\n");
|
||||
render.setCursorPosition({ x: 0, y: 0 });
|
||||
render("World\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.startsWith(hideCursorEscape)).toBe(true);
|
||||
expect(
|
||||
secondCall.endsWith(ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(0) + showCursorEscape),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("no cursor positioning when cursorPosition is undefined", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render("Hello\n");
|
||||
|
||||
const written = stdout.write.firstCall.args[0] as string;
|
||||
expect(written.includes(showCursorEscape)).toBe(false);
|
||||
});
|
||||
|
||||
test("cursor position at second-to-last line emits cursorUp(1)", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.setCursorPosition({ x: 3, y: 2 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
const written = stdout.write.firstCall.args[0] as string;
|
||||
expect(
|
||||
written.endsWith(ansiEscapes.cursorUp(1) + ansiEscapes.cursorTo(3) + showCursorEscape),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("clearing cursor position stops cursor positioning", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.setCursorPosition({ x: 0, y: 0 });
|
||||
render("Hello\n");
|
||||
|
||||
render.setCursorPosition(undefined);
|
||||
render("World\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes(showCursorEscape)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns to bottom before erase when cursor was positioned", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.setCursorPosition({ x: 0, y: 0 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 0 });
|
||||
render("Line A\nLine B\nLine C\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.startsWith(hideCursorEscape)).toBe(true);
|
||||
expect(secondCall.includes(ansiEscapes.cursorDown(3))).toBe(true);
|
||||
expect(secondCall.includes("Line A")).toBe(true);
|
||||
});
|
||||
|
||||
test("sync() without cursor does not write to stream", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, { showCursor: true });
|
||||
|
||||
render.sync("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
expect(stdout.write.callCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Incremental rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("incremental rendering", () => {
|
||||
test("renders and updates output", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Hello\n");
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
expect(stdout.write.firstCall.args[0]).toBe("Hello\n");
|
||||
|
||||
render("World\n");
|
||||
expect(stdout.write.callCount).toBe(2);
|
||||
expect((stdout.write.secondCall.args[0] as string).includes("World")).toBe(true);
|
||||
});
|
||||
|
||||
test("skips identical output", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Hello\n");
|
||||
render("Hello\n");
|
||||
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
});
|
||||
|
||||
test("surgical updates", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render("Line 1\nUpdated\nLine 3\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes(ansiEscapes.cursorNextLine)).toBe(true);
|
||||
expect(secondCall.includes("Updated")).toBe(true);
|
||||
expect(secondCall.includes("Line 1")).toBe(false);
|
||||
expect(secondCall.includes("Line 3")).toBe(false);
|
||||
});
|
||||
|
||||
test("same-height update rewinds cursor to top with trailing newline", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render("Line 1\nUpdated\nLine 3\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.startsWith(ansiEscapes.cursorUp(3))).toBe(true);
|
||||
});
|
||||
|
||||
test("clears extra lines when output shrinks", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render("Line 1\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes(ansiEscapes.eraseLines(2))).toBe(true);
|
||||
});
|
||||
|
||||
test("when output grows", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\n");
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes(ansiEscapes.cursorNextLine)).toBe(true);
|
||||
expect(secondCall.includes("Line 2")).toBe(true);
|
||||
expect(secondCall.includes("Line 3")).toBe(true);
|
||||
expect(secondCall.includes("Line 1")).toBe(false);
|
||||
});
|
||||
|
||||
test("single write call with multiple surgical updates", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\nLine 4\nLine 5\nLine 6\nLine 7\nLine 8\nLine 9\nLine 10\n");
|
||||
render(
|
||||
"Line 1\nUpdated 2\nLine 3\nUpdated 4\nLine 5\nUpdated 6\nLine 7\nUpdated 8\nLine 9\nUpdated 10\n",
|
||||
);
|
||||
|
||||
expect(stdout.write.callCount).toBe(2);
|
||||
});
|
||||
|
||||
test("shrinking output keeps screen tight", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render("Line 1\nLine 2\n");
|
||||
render("Line 1\n");
|
||||
|
||||
const thirdCall = stdout.get();
|
||||
|
||||
expect(thirdCall).toBe(
|
||||
ansiEscapes.eraseLines(2) + ansiEscapes.cursorUp(1) + ansiEscapes.cursorNextLine,
|
||||
);
|
||||
});
|
||||
|
||||
test("clear() fully resets incremental state", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render.clear();
|
||||
render("Line 1\n");
|
||||
|
||||
const afterClear = stdout.get();
|
||||
|
||||
expect(afterClear).toBe(ansiEscapes.eraseLines(0) + "Line 1\n");
|
||||
});
|
||||
|
||||
test("done() resets before next render", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render.done();
|
||||
render("Line 1\n");
|
||||
|
||||
const afterDone = stdout.get();
|
||||
|
||||
expect(afterDone).toBe(ansiEscapes.eraseLines(0) + "Line 1\n");
|
||||
});
|
||||
|
||||
test("multiple consecutive clear() calls (should be harmless no-ops)", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render.clear();
|
||||
render.clear();
|
||||
render.clear();
|
||||
|
||||
expect(stdout.write.callCount).toBe(4);
|
||||
|
||||
render("New content\n");
|
||||
const afterClears = stdout.get();
|
||||
expect(afterClears).toBe(ansiEscapes.eraseLines(0) + "New content\n");
|
||||
});
|
||||
|
||||
test("sync() followed by update (assert incremental path is used)", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render.sync("Line 1\nLine 2\nLine 3\n");
|
||||
expect(stdout.write.callCount).toBe(0);
|
||||
|
||||
render("Line 1\nUpdated\nLine 3\n");
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
|
||||
const firstCall = stdout.write.firstCall.args[0] as string;
|
||||
expect(firstCall.includes(ansiEscapes.cursorNextLine)).toBe(true);
|
||||
expect(firstCall.includes("Updated")).toBe(true);
|
||||
expect(firstCall.includes("Line 1")).toBe(false);
|
||||
expect(firstCall.includes("Line 3")).toBe(false);
|
||||
});
|
||||
|
||||
test("positions cursor after surgical updates", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 1 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
const written = stdout.write.firstCall.args[0] as string;
|
||||
expect(
|
||||
written.endsWith(ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("positions cursor after update", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render.setCursorPosition({ x: 2, y: 0 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render.setCursorPosition({ x: 2, y: 0 });
|
||||
render("Line 1\nUpdated\nLine 3\n");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(
|
||||
secondCall.endsWith(ansiEscapes.cursorUp(3) + ansiEscapes.cursorTo(2) + showCursorEscape),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor positioning (both rendering modes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const modes = [
|
||||
{ name: "standard", incremental: false },
|
||||
{ name: "incremental", incremental: true },
|
||||
] as const;
|
||||
|
||||
function createRenderForMode(incremental: boolean) {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental,
|
||||
});
|
||||
return { stdout, render };
|
||||
}
|
||||
|
||||
describe.each(modes)("$name mode - cursor positioning", ({ incremental }) => {
|
||||
test("clear() returns cursor to bottom before erasing", () => {
|
||||
const { stdout, render } = createRenderForMode(incremental);
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 0 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
render.clear();
|
||||
|
||||
const clearCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(clearCall.includes(hideCursorEscape)).toBe(true);
|
||||
expect(clearCall.includes(ansiEscapes.cursorDown(3))).toBe(true);
|
||||
expect(clearCall.includes(ansiEscapes.eraseLines(4))).toBe(true);
|
||||
});
|
||||
|
||||
test("repositions cursor when only cursor position changes (same output)", () => {
|
||||
const { stdout, render } = createRenderForMode(incremental);
|
||||
|
||||
render.setCursorPosition({ x: 2, y: 0 });
|
||||
render("Hello\n");
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
|
||||
render.setCursorPosition({ x: 3, y: 0 });
|
||||
render("Hello\n");
|
||||
|
||||
expect(stdout.write.callCount).toBe(2);
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes(showCursorEscape)).toBe(true);
|
||||
expect(secondCall.endsWith(ansiEscapes.cursorTo(3) + showCursorEscape)).toBe(true);
|
||||
});
|
||||
|
||||
test("sync() resets cursor state", () => {
|
||||
const { stdout, render } = createRenderForMode(incremental);
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 0 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
render.sync("Fresh output\n");
|
||||
|
||||
render("Updated output\n");
|
||||
|
||||
const afterSync = stdout.get();
|
||||
expect(afterSync.includes(hideCursorEscape)).toBe(false);
|
||||
expect(afterSync.includes(ansiEscapes.cursorDown(3))).toBe(false);
|
||||
});
|
||||
|
||||
test("sync() writes cursor suffix when cursor is dirty", () => {
|
||||
const { stdout, render } = createRenderForMode(incremental);
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 1 });
|
||||
render.sync("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
const written = stdout.write.firstCall.args[0] as string;
|
||||
expect(written).toBe(ansiEscapes.cursorUp(2) + ansiEscapes.cursorTo(5) + showCursorEscape);
|
||||
});
|
||||
|
||||
test("sync() with cursor sets cursorWasShown for next render", () => {
|
||||
const { stdout, render } = createRenderForMode(incremental);
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 1 });
|
||||
render.sync("Line 1\nLine 2\nLine 3\n");
|
||||
|
||||
render("Updated\n");
|
||||
|
||||
const renderCall = stdout.get();
|
||||
expect(renderCall.startsWith(hideCursorEscape)).toBe(true);
|
||||
});
|
||||
|
||||
test("sync() hides cursor when previous render showed cursor", () => {
|
||||
const { stdout, render } = createRenderForMode(incremental);
|
||||
|
||||
render.setCursorPosition({ x: 5, y: 1 });
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
|
||||
render.sync("Fresh output\n");
|
||||
|
||||
expect(stdout.write.callCount).toBe(2);
|
||||
expect(stdout.write.secondCall.args[0] as string).toBe(hideCursorEscape);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No-trailing-newline tests (fullscreen mode)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("incremental rendering - no trailing newline (fullscreen)", () => {
|
||||
test("trailing to no-trailing transition", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("A\nB\n");
|
||||
render("A\nB");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes(ansiEscapes.cursorNextLine)).toBe(true);
|
||||
expect(secondCall.endsWith("\n")).toBe(false);
|
||||
});
|
||||
|
||||
test("no-trailing to no-trailing update", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("A\nB");
|
||||
render("A\nC");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes(ansiEscapes.cursorNextLine)).toBe(true);
|
||||
expect(secondCall.includes("C")).toBe(true);
|
||||
expect(secondCall.endsWith("\n")).toBe(false);
|
||||
});
|
||||
|
||||
test("shrink", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("A\nB");
|
||||
render("A");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes(ansiEscapes.eraseLines(1))).toBe(true);
|
||||
expect(secondCall.endsWith("\n")).toBe(false);
|
||||
});
|
||||
|
||||
test("grow", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("A");
|
||||
render("A\nB\nC");
|
||||
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall.includes("B")).toBe(true);
|
||||
expect(secondCall.includes("C")).toBe(true);
|
||||
expect(secondCall.endsWith("\n")).toBe(false);
|
||||
});
|
||||
|
||||
test("unchanged lines do not overshoot cursor", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("A\nB");
|
||||
render("A\nB"); // identical - should be skipped entirely
|
||||
|
||||
expect(stdout.write.callCount).toBe(1);
|
||||
|
||||
render("X\nB");
|
||||
|
||||
const thirdCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(thirdCall.includes("X")).toBe(true);
|
||||
const lastCursorNextLine = thirdCall.lastIndexOf(ansiEscapes.cursorNextLine);
|
||||
expect(lastCursorNextLine).toBe(-1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render to empty string
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("incremental rendering - render to empty string (full clear vs early exit)", () => {
|
||||
const stdout = createStdout();
|
||||
const render = logUpdate.create(stdout, {
|
||||
showCursor: true,
|
||||
incremental: true,
|
||||
});
|
||||
|
||||
render("Line 1\nLine 2\nLine 3\n");
|
||||
render("\n");
|
||||
|
||||
expect(stdout.write.callCount).toBe(2);
|
||||
const secondCall = stdout.write.secondCall.args[0] as string;
|
||||
expect(secondCall).toBe(ansiEscapes.eraseLines(4) + "\n");
|
||||
|
||||
// Rendering empty string again should be skipped (identical output)
|
||||
render("\n");
|
||||
expect(stdout.write.callCount).toBe(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user